Scribble webhooks: get notified when a card is mailed
For developers
Register an endpoint under Integrations and Scribble POSTs a signed JSON event about a card. Every delivery carries a scribble-signature header: an HMAC-SHA256 of <timestamp>.<raw body> using the endpoint's signing secret. Return any 2xx and we stop; anything else is retried five times over about half a day. Only card.created and card.held fire today — see the table below before you build against the others.
On this page
#Events
| Event | When | Firing now? |
|---|---|---|
card.created | A card has been accepted and scheduled. Nothing physical exists yet. | Yes, for cards sent through the API or an inbound hook |
card.held | Something needs a person, usually an address that cannot be written. No credit was charged. | Yes, for cards sent through the API or an inbound hook |
card.written | The pen has finished the card and the envelope. | No — subscribable, never emitted |
card.mailed | Handed to the carrier. | No — subscribable, never emitted |
card.delivered | A carrier scan confirmed delivery. | No — subscribable, never emitted |
An endpoint subscribed to no specific events receives every event that fires.
#The payload
{
"id": "evt_4EPtVoVpsetMu5taYHXsW3Jc",
"data": {
"card": "SC-H9BYHDA",
"recipient": "Sam Ortiz",
"reference": "crm-4471",
"heldReason": null
},
"type": "card.created",
"created": "2026-08-05T09:39:56.883Z"
}| Header | Value |
|---|---|
content-type | application/json |
user-agent | Scribble-Webhooks/1 |
scribble-event | The event type, e.g. card.mailed |
scribble-delivery | A delivery id, stable across retries of the same event |
scribble-signature | t=<unix seconds>,v1=<hex hmac> |
#Verifying a delivery
The signature is an HMAC-SHA256 over the string <timestamp>.<raw request body>, using the signing secret shown when you added the endpoint, rendered as lowercase hex. The timestamp is inside the signed string so a captured delivery cannot be replayed at you a week later.
import crypto from "node:crypto";
export function verifyScribbleSignature(rawBody, signatureHeader, signingSecret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
if (!parts.t || !parts.v1) return false;
// Reject anything older than five minutes so a captured delivery
// cannot be replayed at you later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import express from "express";
const app = express();
// express.json() would consume the stream and leave you with a parsed
// object you cannot re-serialise byte-for-byte. Capture the raw buffer.
app.post(
"/webhooks/scribble",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
const ok = verifyScribbleSignature(
raw,
req.get("scribble-signature") ?? "",
process.env.SCRIBBLE_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("bad signature");
// Acknowledge FIRST, work afterwards. The delivery times out at
// ten seconds and a slow handler just earns you a retry.
res.status(200).send("ok");
const event = JSON.parse(raw);
void handleAsync(event);
},
);import hmac, hashlib, time
def verify_scribble_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
if "t" not in parts or "v1" not in parts:
return False
if abs(time.time() - int(parts["t"])) > 300:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])#Retries
| Property | Value |
|---|---|
| Success | Any 2xx. We stop. |
| Timeout | 10 seconds |
| Attempts | 5, including the first |
| Backoff | 1 minute, 5, 25, 2 hours, 10 hours |
| Total window | About half a day |
| Auto-disable | 20 consecutive failures switches the endpoint off and records why, rather than hammering a dead URL forever |
#The delivery log
The Integrations screen shows every delivery with its event type, HTTP result, attempt count and timestamp, plus a Send a test button per endpoint and a Retry queued action. An endpoint disabled by 20 consecutive failures says so on screen rather than going quiet.
#Common questions
- Can I have more than one endpoint?
- Yes, each with its own signing secret and its own event subscription.
- Is delivery order guaranteed?
- No. Retries mean a
card.mailedcan arrive after acard.delivered. Treat each event as a state assertion rather than a step in a sequence. - What happens to events queued for a disabled endpoint?
- They are dropped with a reason recorded. Events still inside their retry window when you re-enable the endpoint are not lost.
- Does every card get a card.delivered?
- No. Delivery is only asserted on a carrier scan, and not every piece of mail gets one. Absence of the event is not evidence of non-delivery.
Last checked against the product on . Something wrong or missing? Tell us.