Reference

Webhooks

Event catalogue, signatures, retries and delivery guarantees.

We POST a JSON event to each of your endpoints whenever a checkout session or an escrow created through one changes state. Configure endpoints in Seller dashboard → Integrations.

Request

code
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SmartContractsEscrow-Webhooks/1.0
X-Escrow-Event: escrow.funded
X-Escrow-Event-Id: evt_9f2c1a7b3e4d5c6b7a8f9e0d
X-Escrow-Delivery: 5521
X-Escrow-Signature: t=1789913750,v1=5f0c0f0b9b7e…

{"created":1789913750,"data":{"object":{…escrow…,"previous_status":"PENDING_FUNDING"}},
 "id":"evt_9f2c1a7b3e4d5c6b7a8f9e0d","object":"event","type":"escrow.funded"}

data.object is an Escrow, a Checkout Session, or a milestone (see below). Status-change events include previous_status.

Respond with any 2xx within 10 seconds. Do heavy work asynchronously. Redirects are not followed.

Event catalogue

Eventdata.objectFired whenTypical store action
checkout.session.completedsessionBuyer confirmed; escrow createdOrder → awaiting payment
checkout.session.expiredsessionLink expired or you called /expireCancel unpaid order
escrow.createdescrowEscrow created from a sessionStore escrow.id on the order
escrow.partially_fundedescrowPart-payment receivedNote
escrow.fundedescrowFully funded (IN_ESCROW)Mark paid, ship
escrow.work_startedescrowFirst milestone submitted—
escrow.milestone.submittedmilestoneYou submitted deliveryNote
escrow.milestone.revision_requestedmilestoneBuyer wants changesAlert staff
escrow.milestone.approvedmilestoneBuyer approved; funds for it releasedNote
escrow.milestone.disputedmilestoneBuyer disputed a milestonePut on hold
escrow.completedescrowAll milestones approvedComplete order
escrow.disputedescrowEscrow in disputePut on hold, respond in dashboard
escrow.cancelledescrowCancelled/declinedCancel if unpaid
escrow.expiredescrowDeadline passedReview
escrow.updatedescrowAny other status change—
escrow.milestone.updatedmilestoneAny other milestone change—
ping{message}"Test" button in the console—

Milestone objects also carry escrow (id), checkout_session, external_reference and previous_status.

Verifying signatures

X-Escrow-Signature: t=<unix seconds>,v1=<hex> where

code
v1 = HMAC_SHA256(key = endpoint secret "whsec_…", message = "<t>" + "." + <raw request body>)
  1. Use the raw bytes of the body — not re-serialised JSON.
  2. Compare in constant time.
  3. Reject if |now − t| > 300 s (replay protection).
  4. Accept the request if any v1= entry matches (today there is one; multiple entries are reserved for dual-signing during future secret rotation).

Node (Node SDK):

js
const { constructEvent } = require('@smart-contracts-escrow/node');
app.post('/escrow/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = constructEvent(req.body, req.get('X-Escrow-Signature'), process.env.SCE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(err.message);
  }
  queue.add(event); // process async
  res.sendStatus(200);
});

Python:

python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t = int(parts.get("t", 0))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, v) for k, v in
               (p.split("=", 1) for p in header.split(",")) if k == "v1")

PHP:

php
function sce_verify(string $raw_body, string $header, string $secret, int $tolerance = 300): bool {
    $t = 0; $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
        if ($k === 't') { $t = (int) $v; } elseif ($k === 'v1') { $sigs[] = $v; }
    }
    if (!$t || abs(time() - $t) > $tolerance) { return false; }
    $expected = hash_hmac('sha256', $t . '.' . $raw_body, $secret);
    foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { return true; } }
    return false;
}

Delivery guarantees

  • At-least-once. The same event can arrive more than once — dedupe on id (evt_…).
  • Not ordered. escrow.funded may arrive before checkout.session.completed. Never move an order backwards; when in doubt GET /escrows/{id} for the current state.
  • Retries: non-2xx, timeouts and connection errors are retried after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h, 24 h (9 attempts, ~3 days), then marked FAILED.
  • Auto-disable: after 50 consecutive failed attempts an endpoint is disabled. Re-enable it in the console once fixed.
  • Reconciliation: GET /api/v1/events lists every event, delivered or not.
  • The delivery log in the console shows the last 50 attempts with status codes and errors.

Security

  • Endpoints must be https:// and resolve to a public IP. Private, loopback, link-local (cloud metadata) and reserved ranges are refused at registration and re-checked before every delivery (DNS-rebinding defence).
  • Rotate a secret from the console at any time; the new secret applies to deliveries sent after rotation, so update your store first or briefly accept both.