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

EventWhenWhat to do
payment_session.checkout_startedThe human opened the handoff link and a checkout exists, unpaidNothing; wait
payment_session.paidStripe confirmed the payment; the label is being boughtExpect label_ready within minutes
payment_session.label_readyThe label exists; data.checkout.tracking_number is setHand the tracking number on; track_parcel takes it
payment_session.deliveredThe carrier reported deliveryClose the loop
payment_session.refundedThe order was refunded or is being refundedDo not use any label from this checkout
payment_session.cancelledYou cancelled the session with DELETE /agent/checkout-session/{session_id} before the human opened itCreate 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 new callback_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_id and the OAuth client behind an external_id can 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.

Related

DELETE /agent/checkout-session/{session_id} cancels a session the human has not opened yet (status awaiting_checkout); from checkout_started on it answers 409 session_in_checkout. dry_run: true on createPaymentSession prices and validates without storing anything. Both are described in the OpenAPI document and the developer portal; the limits that apply are on the rate limits page.