Setting it up
Send callback_url with createPaymentSession (POST /agent/checkout-session). It must be an absolute https URL on a public host: IP addresses, private or reserved names, credentials in the URL and smklog.com hosts are refused with 400 invalid_request. Bring your own callback_secret (16 to 128 characters) or leave it out and SMKlog generates one and returns it once, in the answer that stored it.
POST https://quote-api.smklog.com/agent/checkout-session
Content-Type: application/json
{"quote_id": "q_8f3c2e1a4b7d",
"service": "USPS Ground Advantage",
"callback_url": "https://agent.example.com/hooks/smklog"}
HTTP/1.1 200 OK
{"session_id": "as_3f9c1b2e-7d64-4a8e-9c0f-5b2d1e8a7c33",
"created": true,
"callback_url": "https://agent.example.com/hooks/smklog",
"callback_secret": "c0ffee…48 hex characters…",
"handoff_url": "https://smklog.com/assets/calculator?…&agent_session=as_3f9c…",
…}
On an update by external_id, omit the two fields to keep the stored webhook, send a new callback_url to replace it (a new secret is generated unless you send one), or send "callback_url": null to remove it. Nothing is sent for awaiting_checkout: you made the session, you know.
The six events
| Event | When | What to do |
|---|---|---|
payment_session.checkout_started | The human opened the handoff link and a checkout exists, unpaid | Nothing; wait |
payment_session.paid | Stripe confirmed the payment; the label is being bought | Expect label_ready within minutes |
payment_session.label_ready | The label exists; data.checkout.tracking_number is set | Hand the tracking number on; track_parcel takes it |
payment_session.delivered | The carrier reported delivery | Close the loop |
payment_session.refunded | The order was refunded or is being refunded | Do not use any label from this checkout |
payment_session.cancelled | You cancelled the session with DELETE /agent/checkout-session/{session_id} before the human opened it | Create a new session for a new attempt |
One delivery per stage the session reaches; a second checkout draft from the same link is not a second event. Stages can be skipped when they are observed together (a label bought in the same breath as the payment arrives as label_ready). Read data.status, which is the whole CheckoutStatus of that moment, rather than counting events.
What arrives
POST https://agent.example.com/hooks/smklog
Content-Type: application/json
User-Agent: SMKlog-Webhooks/1.0 (+https://smklog.com/developers/webhooks)
X-Smklog-Event: payment_session.label_ready
X-Smklog-Delivery: wh_7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
X-Smklog-Signature: t=1760880000,v1=3b7f2a1c…64 hex characters…
{"id": "wh_7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"event": "payment_session.label_ready",
"created_at": "2026-10-12T15:31:12.000Z",
"session_id": "as_3f9c1b2e-7d64-4a8e-9c0f-5b2d1e8a7c33",
"data": {"session_id": "as_3f9c…", "status": "label_ready", "amount": 38.42, "currency": "USD",
"service": "USPS Ground Advantage",
"checkout": {"order_id": "c1d2e3f4-…", "paid": true, "label_ready": true,
"carrier": "USPS", "tracking_number": "9400100000000000000000", …},
"next": "Label bought. Pass tracking_number to track_parcel for scan events."}}
The body is the WebhookEvent schema of the OpenAPI document (its webhooks section describes this very request). It never carries names, addresses or emails. id stays the same on every retry of one event, so deduplicate on it.
Verifying the signature
X-Smklog-Signature is t=<unix seconds>,v1=<hex>, where hex is HMAC-SHA256 over the string <t>.<raw body> keyed with your callback_secret. Compute it over the raw bytes you received, before any JSON parsing, compare in constant time, and reject a t older than five minutes.
// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || '');
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
# Python
import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header or "")
if not m or abs(time.time() - int(m.group(1))) > 300:
return False
expected = hmac.new(secret.encode(), m.group(1).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m.group(2))
Answer any 2xx within 8 seconds. Do the work after answering; a slow handler is a timed-out delivery and a retry you then have to deduplicate.
Retries
Anything but a 2xx, a redirect (never followed), a timeout or a connection error schedules the next attempt: after 1 minute, then 5, 15 and 60 minutes, then 4 and 12 hours. Seven attempts in all, about eighteen hours, then the delivery is given up. The first attempt is made as soon as the stage is observed, in the same request that observed it; retries run on a 15-minute schedule, so the exact moment of a retry moves by up to 15 minutes. If your endpoint was down for a day, read the session with getCheckoutStatus rather than waiting for a delivery that will not come.
Security notes
- The secret is stored by SMKlog to sign deliveries and shown once. To rotate it, send the session a new
callback_url(the same URL is fine) with a newcallback_secret, or without one to have a fresh one generated. - Only https, only public hosts, no credentials in the URL, redirects not followed: a callback cannot be pointed at a private network or bounced elsewhere.
- Nobody but the holder of the
session_idand the OAuth client behind anexternal_idcan change a session's webhook. A webhook consumer learns a tracking number, never a name or an address. - A webhook is not a charge notification from Stripe. SMKlog reports what its own order record says; the money moved on Stripe hosted checkout, between the human and SMKlog, and no API caller is a party to it.