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
| Event | data.object | Fired when | Typical store action |
|---|---|---|---|
checkout.session.completed | session | Buyer confirmed; escrow created | Order → awaiting payment |
checkout.session.expired | session | Link expired or you called /expire | Cancel unpaid order |
escrow.created | escrow | Escrow created from a session | Store escrow.id on the order |
escrow.partially_funded | escrow | Part-payment received | Note |
escrow.funded | escrow | Fully funded (IN_ESCROW) | Mark paid, ship |
escrow.work_started | escrow | First milestone submitted | — |
escrow.milestone.submitted | milestone | You submitted delivery | Note |
escrow.milestone.revision_requested | milestone | Buyer wants changes | Alert staff |
escrow.milestone.approved | milestone | Buyer approved; funds for it released | Note |
escrow.milestone.disputed | milestone | Buyer disputed a milestone | Put on hold |
escrow.completed | escrow | All milestones approved | Complete order |
escrow.disputed | escrow | Escrow in dispute | Put on hold, respond in dashboard |
escrow.cancelled | escrow | Cancelled/declined | Cancel if unpaid |
escrow.expired | escrow | Deadline passed | Review |
escrow.updated | escrow | Any other status change | — |
escrow.milestone.updated | milestone | Any 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>)
- Use the raw bytes of the body — not re-serialised JSON.
- Compare in constant time.
- Reject if
|now − t| > 300 s(replay protection). - 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.fundedmay arrive beforecheckout.session.completed. Never move an order backwards; when in doubtGET /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/eventslists 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.