Browse documentation

Type to search the documentation. Press Esc to close.

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

Webhook events, and which of them fire today
EventWhenFiring now?
card.createdA card has been accepted and scheduled. Nothing physical exists yet.Yes, for cards sent through the API or an inbound hook
card.heldSomething 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.writtenThe pen has finished the card and the envelope.No — subscribable, never emitted
card.mailedHanded to the carrier.No — subscribable, never emitted
card.deliveredA carrier scan confirmed delivery.No — subscribable, never emitted

An endpoint subscribed to no specific events receives every event that fires.

#The payload

A real card.created delivery body
{
  "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"
}
Headers on every delivery
HeaderValue
content-typeapplication/json
user-agentScribble-Webhooks/1
scribble-eventThe event type, e.g. card.mailed
scribble-deliveryA delivery id, stable across retries of the same event
scribble-signaturet=<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.

Node — verified against a real delivery
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);
}
Express — getting the raw body right
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);
  },
);
Python — verified against a real delivery
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

Delivery and retry behaviour
PropertyValue
SuccessAny 2xx. We stop.
Timeout10 seconds
Attempts5, including the first
Backoff1 minute, 5, 25, 2 hours, 10 hours
Total windowAbout half a day
Auto-disable20 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.mailed can arrive after a card.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.