InboundXPublisher docs

On this page: Request headers

Advanced · Reference

Receive signed webhook events

Verify each event, process it once, and acknowledge it after your work is saved.

Configure a public HTTPS destination in your webhook settings. Choose GET, POST or PUT and the events you need. Save the signing secret shown when you create or rotate the webhook: it is shown only once. Use that exact string as a UTF-8 HMAC key; do not decode it from base64.

Request headers

HeaderMeaning
x-ix-delivery-idA stable UUID for this delivery, including retries and Redeliver.
x-ix-eventThe subscribed event name.
x-ix-timestampUnix time in whole seconds, refreshed for each attempt.
x-ix-signaturesha256= followed by the lowercase hexadecimal HMAC-SHA256 digest.

Verify before processing

Verify the raw body bytes before parsing JSON or processing the event. Changing whitespace or rebuilding JSON changes the signature. A GET with no body signs an empty body.

Reject an invalid signature or a timestamp more than 300 seconds ahead of or behind your clock. Keep your clock synchronized. This timestamp check limits replay; it does not replace deduplication.

The signed message contains five values separated by one LF byte (a line break), with no trailing newline. The method is uppercase.

Signed message
delivery_id
timestamp
UPPERCASE_METHOD
public_url
raw_body

Use the exact destination URL, including the rendered query string and its order. Normalize it with URL parsing and remove its fragment. Behind a proxy, use your configured public scheme and host plus the original path and query. The internal HTTP URL may be different.

Calculate HMAC-SHA256 with the saved secret. Validate the received digest’s format and length before comparing with a constant-time comparison.

Copy the Node.js verifier

This standalone TypeScript example uses Node.js built-ins. Pass the saved secret, the delivery ID, timestamp and signature headers, request method, public URL and unparsed Buffer. Continue only when it returns true. The copied function is the same one checked against independent signature test vectors.

TypeScript · Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

/** Standalone receiver example: call before parsing or processing the request body. */
export function verifyDelivery(input: {
  secret: string;
  deliveryId: string;
  timestamp: string;
  signature: string;
  method: string;
  publicUrl: string;
  rawBody: Buffer;
  nowSeconds?: number;
}): boolean {
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.deliveryId)) return false;
  if (!/^\d{1,16}$/.test(input.timestamp)) return false;
  const timestamp = Number(input.timestamp);
  const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(timestamp) || !Number.isFinite(now) || Math.abs(now - timestamp) > 300) return false;
  if (!/^sha256=[0-9a-f]{64}$/.test(input.signature)) return false;
  let publicUrl: string;
  try {
    const url = new URL(input.publicUrl);
    url.hash = "";
    publicUrl = url.href;
  } catch { return false; }
  const prefix = [input.deliveryId, input.timestamp, input.method.toUpperCase(), publicUrl, ""].join("\n");
  const expected = createHmac("sha256", input.secret).update(prefix, "utf8").update(input.rawBody).digest();
  const received = Buffer.from(input.signature.slice(7), "hex");
  return timingSafeEqual(expected, received);
}

Use one value for each header. Reject missing or repeated headers before calling the verifier. GET and PUT follow the same verification rules.

Process each delivery once

Delivery is at least once. Your server can save its work and lose the HTTP response; a retry may repeat an event you already processed. Redeliver also keeps the original delivery ID.

Store the delivery ID in an inbox table with a UNIQUE constraint, in the same database transaction as your business write. If the ID already exists, skip the write and return success. Commit before acknowledging. A database constraint also protects against concurrent duplicate requests; an in-memory set cannot survive a restart.

SQL · durable inbox
begin;
insert into webhook_inbox(delivery_id) values ($1)
on conflict (delivery_id) do nothing returning delivery_id;
-- If inserted: perform the business write in this transaction.
commit;
-- Respond 2xx after commit, including an already-processed delivery.

Keep the inbox ID for the business record’s lifetime, including possible manual redelivery. Pending events can stay queued while delivery is off. A short timestamp tolerance does not tell you when it is safe to delete an inbox ID.

Acknowledge or request a retry

Return 2xx after committing to acknowledge delivery, including a duplicate. Return 5xx, 408 or 429 for a temporary failure. Redirects and other 4xx responses are final failures; redirects are not followed.

The default is five total attempts. The first retry waits 30 seconds; later delays double, up to one hour. Your webhook setting can change the attempt budget. Each HTTP attempt has a five-second deadline.

An owner or permitted staff member can Redeliver a dead event after fixing the receiver. The action is recorded, resets the attempt budget and keeps the delivery ID. Fix a rejected signature or permanent destination error first. Paused webhooks and delivery switches prevent sending.

Rotate the signing secret

Pause the webhook, rotate and save its secret, copy the newly displayed value into your receiver, then resume. A request already in progress may use the previous secret: briefly accept both during the handover. New attempts, including retries of older events, use the current secret.

The delivery log keeps delivered and dead rows for 30 days. Pending, failed and sending rows remain available for recovery.

Your ping keys come from the buyer you send calls to. Ask them for a key, or for help with your setup.© InboundX