Skip to content

Webhooks ​

A webhook pushes a store event to an HTTPS endpoint you control. Use one when an external system — a fulfillment service, an ERP, a Slack relay, a data warehouse — needs to know about orders without polling the REST API.

Ambikly signs every delivery, queues it rather than sending it inline, and retries it with backoff for up to eight attempts.

Creating a webhook ​

From the admin ​

  1. Go to Ambikly → Webhooks.
  2. Click New webhook.
  3. Enter the URL. It must be http or https — any other scheme is rejected.
  4. Give it a Name. Leave it blank and the hostname is used.
  5. Pick the Events that should trigger a delivery.
  6. Save, then use Copy secret straight away — it is shown once.
  7. Click Test to confirm your receiver responds with a 2xx.

Webhook management requires manage_options. A Store Manager cannot reach this screen.

From the REST API ​

bash
curl -u 'admin:app password' \
  -X POST 'https://example.com/wp-json/ambikly/v1/webhooks' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Fulfillment bridge",
    "url": "https://ops.acme.example/hooks/ambikly",
    "events": ["order.paid", "order.status_changed"],
    "active": 1
  }'
json
{
  "success": true,
  "data": {
    "id": 3,
    "name": "Fulfillment bridge",
    "url": "https://ops.acme.example/hooks/ambikly",
    "secret": "kQ3nXv8pR2tYwL6mHgJzD4sFbN9cA1eU7oP0iK5lQ2s",
    "events": ["order.paid", "order.status_changed"],
    "active": true,
    "last_fired_at": null,
    "last_status": 0,
    "failure_count": 0
  }
}

Omit secret and a 32-byte base64url secret is generated for you. Omit events and the webhook is created subscribed to nothing.

Subscribe to everything, including events added in future versions, with the wildcard:

json
{ "events": ["*"] }

The secret is shown once ​

POST /webhooks returns the real secret in its response body. Every later read masks it — GET /webhooks and GET /webhooks/{id} replace all but the last four characters with bullets, so you can confirm which secret is configured but not recover it.

Rotation means delete and recreate

PUT /webhooks/{id} explicitly discards any secret in the request body. There is no rotate endpoint and no admin control for it. To change a secret, delete the webhook and create a new one — which also deletes its delivery log. Plan a brief window where your receiver accepts either the old or the new secret.

Events ​

Nine events. GET /webhooks/events returns the live catalog.

EventUnderlying actiondata keys
order.createdambikly_order_createdorder
order.paidambikly_order_paidorder
order.status_changedambikly_order_status_changedorder, old_status, new_status
order.refundedambikly_order_refundedorder, refund
checkout.completedambikly_checkout_completedorder, payment
review.submittedambikly_review_submittedreview
subscription.renewedambikly_subscription_renewedsubscription, renewal_order
subscription.cancelledambikly_subscription_cancelledsubscription
subscription.renewal_failedambikly_subscription_renewal_failedsubscription, renewal_order, error_code, error_message

Each data key holds a flattened array of the corresponding model. Class names never appear on the wire.

Order payloads carry their line items ​

An order object is decorated with an items array so you do not need a second API call to know what was bought.

Fields that are never sent ​

Keys named secret, password, pin_hash or webhook_secret are stripped recursively from every payload before it is sent. A gift card's PIN hash and a webhook's own secret can never leak through a delivery.

subscription.* events need Ambikly Pro

All nine events are subscribable in the free core, but the three subscription.* ones hook actions that only Ambikly Pro's Subscriptions add-on emits. Subscribing to them on a free-only site is accepted and simply never fires. They deliver only when the Subscriptions add-on is enabled and the Pro license is currently valid — a lapsed license stops the add-on booting, which stops the events.

checkout.completed can carry a null payment ​

The underlying action fires twice, and the thank-you page path for redirect gateways (Stripe, PayPal) passes null as the payment. Your receiver will see "payment": null for those. Handle it.

The envelope ​

Every delivery is a POST with a JSON body in this exact shape:

json
{
  "id": "evt_9f2b41c8ad7e5301b6c2f4a9",
  "event": "order.paid",
  "created_at": "2026-03-14T09:21:07+00:00",
  "data": {
    "order": {
      "id": 1044,
      "order_number": "AMB-1044",
      "status": "processing",
      "payment_status": "paid",
      "currency": "USD",
      "subtotal": "58.00",
      "shipping_total": "5.50",
      "tax_total": "0.00",
      "total": "63.50",
      "customer_email": "[email protected]",
      "payment_method": "stripe",
      "payment_method_title": "Credit card",
      "created_at": "2026-03-14 09:20:55",
      "items": [
        {
          "id": 2210,
          "product_id": 42,
          "name": "Blue Mug",
          "sku": "MUG-BLUE",
          "quantity": 2,
          "price": "18.00",
          "line_total": "36.00"
        }
      ]
    }
  }
}

id is unique per delivery attempt group, prefixed evt_ followed by 24 hex characters. created_at is UTC in ISO 8601. A test event additionally carries "test": true and an id prefixed evt_test_.

Request headers ​

HeaderValue
Content-Typeapplication/json
User-AgentAmbikly/{version}
X-Ambikly-EventThe dot-notation event name
X-Ambikly-Signaturesha256=<hex>

Verifying the signature ​

X-Ambikly-Signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's secret.

Two rules matter more than anything else on this page:

  1. Compute the HMAC over the raw body, before any parsing. Decoding JSON and re-encoding it will reorder keys, change number formatting and change whitespace. The signature will not match and you will conclude the feature is broken.
  2. Compare in constant time. hash_equals() in PHP, crypto.timingSafeEqual() in Node. A plain === leaks timing information that can be used to forge a signature.

PHP ​

A complete, self-contained receiver:

php
<?php
/**
 * Ambikly webhook receiver.
 * Responds 2xx only after the signature verifies.
 */

const AMBIKLY_WEBHOOK_SECRET = 'kQ3nXv8pR2tYwL6mHgJzD4sFbN9cA1eU7oP0iK5lQ2s';

// 1. Read the RAW body. Never json_decode() before verifying.
$raw = file_get_contents('php://input');

// 2. Read the header. Apache exposes it as HTTP_X_AMBIKLY_SIGNATURE.
$header = $_SERVER['HTTP_X_AMBIKLY_SIGNATURE'] ?? '';

// 3. Reject anything that is not in the documented sha256=<hex> form.
if (strpos($header, 'sha256=') !== 0) {
    http_response_code(400);
    exit('Missing or malformed signature');
}
$provided = substr($header, 7);

// 4. Compute the expected signature over the raw body.
$expected = hash_hmac('sha256', $raw, AMBIKLY_WEBHOOK_SECRET);

// 5. Constant-time comparison. hash_equals() is not optional.
if (!hash_equals($expected, $provided)) {
    http_response_code(401);
    exit('Bad signature');
}

// 6. Only now is it safe to parse.
$payload = json_decode($raw, true);
if (!is_array($payload)) {
    http_response_code(400);
    exit('Bad JSON');
}

// 7. Ignore a replay of an event id you have already processed.
if (acme_event_already_seen($payload['id'])) {
    http_response_code(200);
    exit('Already processed');
}

switch ($payload['event']) {
    case 'order.paid':
        acme_queue_fulfillment($payload['data']['order']);
        break;
    case 'order.status_changed':
        acme_sync_status(
            $payload['data']['order']['id'],
            $payload['data']['old_status'],
            $payload['data']['new_status']
        );
        break;
}

acme_mark_event_seen($payload['id']);

// 8. Any 2xx counts as success. Return quickly.
http_response_code(200);
echo 'ok';

Node ​

js
const express = require('express');
const crypto = require('crypto');

const SECRET = process.env.AMBIKLY_WEBHOOK_SECRET;
const app = express();

// Capture the RAW body. express.json() alone would give you a parsed
// object with no way to recover the exact bytes that were signed.
app.post(
  '/hooks/ambikly',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('X-Ambikly-Signature') || '';
    if (!header.startsWith('sha256=')) {
      return res.status(400).send('Missing or malformed signature');
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(req.body) // req.body is a Buffer of the raw bytes
      .digest('hex');

    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(header.slice(7), 'utf8');

    // timingSafeEqual throws on a length mismatch, so check first.
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('Bad signature');
    }

    const payload = JSON.parse(req.body.toString('utf8'));

    // Acknowledge fast, then do the work.
    res.status(200).send('ok');
    handle(payload).catch((err) => console.error(err));
  }
);

async function handle(payload) {
  switch (payload.event) {
    case 'order.paid':
      await queueFulfillment(payload.data.order);
      break;
    case 'order.refunded':
      await reverseFulfillment(payload.data.order, payload.data.refund);
      break;
  }
}

app.listen(3000);

Body parsers silently break verification

Express's express.json(), Rails' parameter parsing, Laravel's request handling and most API gateways consume the raw body before your handler sees it. Reach for the framework's raw-body option — express.raw(), request.body in Rails, $request->getContent() in Laravel — for this route specifically.

Delivery behavior ​

Queued, never inline ​

An event does not trigger an HTTP request during the customer's checkout. The dispatcher subscribes at priority 999, so it runs after every other listener has seen the event, then enqueues a webhook_deliver job with kick enabled. A non-blocking loopback request asks the queue to run immediately instead of waiting up to a minute for the next cron tick.

The practical consequences:

  • A slow or unreachable receiver never adds latency to a shopper's checkout or an admin's status change.
  • Delivery order is not guaranteed. Two events fired milliseconds apart can arrive in either order. Use created_at or the order's own state, not arrival order.
  • A very low-traffic site with no cron traffic can see the tick fall behind, because WP-Cron only runs on an incoming request.

The only synchronous send is the admin's Test button (POST /webhooks/{id}/test), which reports the real result rather than "queued".

Retries ​

PropertyValue
Attempts before giving up8
Backoff after attempt 11 minute
After attempt 25 minutes
After attempt 330 minutes
After attempt 42 hours
After attempt 56 hours
After attempts 6 and 724 hours each
Request timeout5 seconds

An attempt is a failure when the request errors at the transport level, or when the response status is anything outside 200–299. A redirect that is not followed to a 2xx counts as a failure.

Roughly 56 hours elapse between the first attempt and the eighth. After the eighth failure the job is left in ambikly_jobs with status = 'failed', visible at GET /jobs/failed and retryable with POST /jobs/{id}/retry.

Two conditions stop retrying early. If the webhook has been deleted since the job was queued, the job completes silently — there is nothing left to deliver to. If the webhook has been deactivated, the same: retries stop rather than hammering a URL nobody wants delivered to.

The webhook row itself is updated after every attempt with last_fired_at, last_status and a failure_count that resets to zero on any success.

Delivery log ​

Every attempt is recorded in ambikly_webhook_deliveries — queued attempts and manual tests alike.

RetainedDetail
Rows keptThe most recent 200 per webhook. Older rows are deleted after each attempt
Readable atGET /webhooks/{id}/deliveries — most recent 200, newest first
Request body storedFirst 4096 characters
Response body storedFirst 4096 characters
Error text storedFirst 500 characters
Also storedstatus_code, duration_ms, succeeded, attempt, processed_at

A busy webhook rotates 200 rows quickly. Do not treat the delivery log as an audit trail — if you need one, log on your own side.

Deleting a webhook deletes its delivery log with it.

Outbound request safety ​

Deliveries go out through wp_safe_remote_post(), WordPress core's SSRF-hardened HTTP client. It resolves the destination host and refuses loopback, private and reserved IP ranges. A webhook pointed at 127.0.0.1, a 10.x address or a cloud metadata endpoint will not be fetched.

This matters because response bodies are stored and returned verbatim by GET /webhooks/{id}/deliveries. Without the restriction, a webhook URL would be a read primitive into the host's internal network.

Testing against a local receiver

wp_safe_remote_post() blocks private addresses, so a service on localhost cannot be reached. Use a public tunnel (ngrok, Cloudflare Tunnel, or similar) and point the webhook at the public hostname.

Building a reliable receiver ​

DoWhy
Return 2xx as soon as the signature verifiesThe 5-second timeout is wall-clock. Slow work turns a success into a retry
Do the real work asynchronouslySame reason
De-duplicate on idRetries and a redelivery after a timeout both produce the same event twice
Do not assume orderingDeliveries are queued independently
Log the attempt numberX-Ambikly-Event plus the payload id tells you whether you are seeing a retry
Accept unknown event namesNew events can appear in a future version, and "*" subscribes to them
Serve over HTTPSThe body contains customer email addresses and order totals

A receiver that times out but has already done the work is the classic failure mode: Ambikly records a failure and retries, and you process the same order twice. De-duplication on id is what prevents that.

Troubleshooting ​

SymptomLikely cause
Signature never matchesThe body is being parsed before the HMAC is computed
Signature never matches, raw body confirmedThe secret is wrong. It cannot be re-read after creation — delete and recreate
Nothing arrives at allThe webhook is inactive, not subscribed to that event, or a subscription.* event on a site without the Pro Subscriptions add-on
Nothing arrives, webhook looks correctWP-Cron is not running. Check GET /jobs/status for a growing pending count
Deliveries stop after a whileEight attempts exhausted. Check GET /jobs/failed
Test event works, real events do notThe test is synchronous; real events go through the queue. Look at the cron tick
last_status shows 0A transport error, not an HTTP response — DNS, TLS or the SSRF guard blocking a private address

GET /webhooks/{id}/deliveries is the first place to look. It carries the status code, duration, error text and a truncated copy of both bodies for every recent attempt.

Next ​

WP-CLI covers the three commands for migrations, CSV export and recomputation.