← Back to docs

Webhooks

instxnt fires webhooks for order lifecycle events. Each delivery is HMAC-signed, retried on failure, and idempotency-keyed so you can safely deduplicate.

Events

EventFires when
order.paidStripe confirms payment for an order. The buyer's card has cleared.
order.fulfilledOrder has been forwarded to a fulfillment provider (Printify, CJ Dropshipping) or marked as shipped manually.
order.refundedOrder has been fully refunded via Stripe.
order.partially_refundedOrder has been partially refunded. Includes the refunded amount.
order.dispute_openedStripe has informed us of a chargeback / dispute on this order.
store.publishedA draft store has gone live (subdomain is now serving the storefront).

Payload format

All events share the same envelope:

{
  "id": "evt_01HXXXXXXXXXXXXXXX",            // unique event id
  "type": "order.paid",
  "created_at": "2026-04-26T14:23:01.123Z",
  "store_id": "st_abc123",
  "data": {
    "order": {
      "id": "or_xyz789",
      "stripe_payment_intent": "pi_3Q...",
      "total_cents": 3000,
      "currency": "USD",
      "buyer": { "email": "buyer@example.com", "name": "Jane D." },
      "shipping_address": { "line1": "...", "city": "...", "country": "US" },
      "items": [
        { "product_id": "pr_...", "title": "Magnetic Phone Mount", "quantity": 1, "unit_price_cents": 3000 }
      ]
    }
  }
}

Use idfor idempotency. instxnt may retry the same event after a network blip; if you've seen the id before, ignore.

Signature verification

Every webhook includes an X-Instxnt-Signature header containing an HMAC-SHA256 of the raw request body, signed with the secret you configured for the endpoint.

Format: t=<unix_timestamp>,v1=<hex_signature>

Verify in Node.js

import crypto from 'node:crypto';

function verify(req: Request, rawBody: string, secret: string) {
  const header = req.headers.get('x-instxnt-signature') || '';
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  if (!parts.t || !parts.v1) return false;

  // Reject events older than 5 minutes (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(parts.t);
  if (age > 300) return false;

  const signed = `${parts.t}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
  // Constant-time compare
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Verify in Python

import hmac, hashlib, time

def verify(headers, raw_body: bytes, secret: str) -> bool:
    header = headers.get("X-Instxnt-Signature", "")
    parts = dict(p.split("=") for p in header.split(","))
    if not parts.get("t") or not parts.get("v1"):
        return False

    if int(time.time()) - int(parts["t"]) > 300:
        return False

    signed = f"{parts['t']}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Verify on Cloudflare Workers

export async function verify(req: Request, rawBody: string, secret: string): Promise<boolean> {
  const header = req.headers.get('x-instxnt-signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  if (!parts.t || !parts.v1) return false;
  if (Math.floor(Date.now() / 1000) - Number(parts.t) > 300) return false;

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );
  const signed = new TextEncoder().encode(`${parts.t}.${rawBody}`);
  const sig = Uint8Array.from(parts.v1.match(/.{2}/g)!.map(h => parseInt(h, 16)));
  return crypto.subtle.verify('HMAC', key, sig, signed);
}

Always verify against the raw request body bytes, not a JSON-parsed-and-restringified version. Whitespace differences will break the signature.

Retries

Your endpoint must return a 2xx within 10 seconds. Anything else (3xx, 4xx, 5xx, timeout) triggers a retry.

Retry schedule, with exponential backoff:

  • Attempt 1: immediate
  • Attempt 2: 30s later
  • Attempt 3: 5 min later
  • Attempt 4: 30 min later
  • Attempt 5: 2 hours later
  • Attempt 6: 12 hours later (final)

After 6 failed attempts the event is marked dead. You can replay dead events from the dashboard at /dashboard/webhooks within 30 days; after that they are purged.

Setting up an endpoint

  1. In the dashboard, go to Webhooks in the left nav.
  2. Click Add endpoint. Enter your URL (must be HTTPS, must respond 200 to a GET ping).
  3. Pick which events to subscribe to. You can subscribe to all of them or just a subset.
  4. Copy the signing secret. instxnt shows it once — store it in your secret manager. If you lose it you can rotate to a new one.
  5. Send a test event from the dashboard to verify your endpoint accepts and verifies signatures correctly before a real order arrives.

Testing locally

Use a tunnel like ngrok or Cloudflare Tunnel to expose your local server to the internet, then point your webhook endpoint at the tunnel URL.

# expose localhost:3000 over a tunnel
ngrok http 3000

# in the instxnt dashboard, add the ngrok URL as a webhook endpoint:
#   https://<random-id>.ngrok.io/api/webhooks/instxnt

# trigger a test event
# (Webhooks → Send test event → "order.paid")

Common gotchas

  • Body parsing strips bytes. Frameworks that auto-parse JSON before signature verification will pass the wrong body to your verifier. Read the raw body first, verify, then parse.
  • Idempotency keys. Network retries will deliver the same id twice. Store seen ids for at least 24 hours.
  • Stripe webhooks are separate. instxnt receives Stripe's webhooks internally to drive the order.paid event. You do not need to subscribe to Stripe directly — listen to instxnt webhooks instead.
  • Replay protection. Events older than 5 minutes (per the timestamp in the signature header) should be rejected even if the signature is valid. This blocks replay attacks if your secret ever leaks.

Need a webhook event we don't have? Write us.

support@instxnt.xyz