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 up to five more times over about half a day.
On this page
#Events
| Event | When | Fires for |
|---|---|---|
card.created | A card has been accepted and scheduled. Nothing physical exists yet. | Cards sent through the API or an inbound hook |
card.written | The pen has finished the card and the envelope. | Every card belonging to your organization |
card.mailed | Handed to the carrier. The event most systems act on. | Every card belonging to your organization |
card.held | Something needs a person, usually an address that cannot be written. No credit was charged. | Cards sent through the API or an inbound hook |
card.cancelled | A card was stopped before it was made, and its credit went back. | Cards canceled through the API |
An endpoint subscribed to no specific events receives every event, including any we add later. Subscribe to a specific list only if you have a reason to.
#Registering an endpoint from code
You can add an endpoint by hand under Integrations, or register one over the API. The API route exists because a marketplace app has to subscribe and unsubscribe on the user's behalf when they switch an automation on and off — nobody installs a connector that needs a support article to turn on.
#POST/api/v1/webhooks
Register an endpoint. Returns its signing secret once.
Requires scope webhooks:manage
Body parameters
| Field | Type | Description |
|---|---|---|
urlrequired | string | Where to POST events. Must be https:// — these payloads carry recipient names. |
events | string[] | Which events to receive. Leave it out to receive all of them, including any we add later. |
description | string | What this endpoint is for. Shown in the app beside it. |
curl -X POST https://scribblecards.com/api/v1/webhooks \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/scribble",
"events": ["card.mailed"]
}'{
"id": "c3e26043-df6a-4d59-a388-6a944ac9d621",
"object": "webhook_endpoint",
"url": "https://hooks.example.com/scribble",
"events": ["card.mailed"],
"active": true,
"signing_secret": "whsec_ZgkkpkzuvcG7RgehBzKXRuJfkyq252eb",
"note": "Store `signing_secret` now — it is not shown again."
}signing_secretis shown exactly once. Store it before you do anything else; it is the only thing that proves a delivery came from us.- Registering a URL that already has an endpoint returns the existing one with
signing_secret: nullrather than creating a second. Two endpoints on one URL would deliver everything twice. http://is refused. So is any URL pointing back at scribblecards.com.
#DELETE/api/v1/webhooks/{id}
Stop sending to an endpoint, and drop anything still queued for it.
Requires scope webhooks:manage
- Deleting an endpoint that is already gone is a
200, not a404, so a retried unsubscribe cannot surface to a user as a broken connection.
#GET/api/v1/webhooks
List your endpoints, with their failure counts and last success. Useful for a health check screen.
Requires scope webhooks:manage
#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-serialize 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 | 6, including the first |
| Backoff | 1 minute, then 5, then 25, then 2 hours, then 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.writtencan arrive after acard.mailed. 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.
- Is there a delivered event?
- No, and there deliberately is not one. We do not buy USPS tracking, and First Class mail is not scanned to the door anyway — so we could not fire it honestly.
card.mailedis the last thing we know for certain: the card is with the carrier. Anything past that would be a guess dressed up as a fact.
Last checked against the product on . Something wrong or missing? Tell us.
