Skip to main content
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 up to five more times over about half a day.

On this page

#Events

Every webhook event
EventWhenFires for
card.createdA card has been accepted and scheduled. Nothing physical exists yet.Cards sent through the API or an inbound hook
card.writtenThe pen has finished the card and the envelope.Every card belonging to your organization
card.mailedHanded to the carrier. The event most systems act on.Every card belonging to your organization
card.heldSomething needs a person, usually an address that cannot be written. No credit was charged.Cards sent through the API or an inbound hook
card.cancelledA 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

FieldTypeDescription
urlrequiredstringWhere to POST events. Must be https:// — these payloads carry recipient names.
eventsstring[]Which events to receive. Leave it out to receive all of them, including any we add later.
descriptionstringWhat this endpoint is for. Shown in the app beside it.
Subscribe
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"]
  }'
201 Created
{
  "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_secret is 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: null rather 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 a 404, 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

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-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);
  },
);
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 behavior
PropertyValue
SuccessAny 2xx. We stop.
Timeout10 seconds
Attempts6, including the first
Backoff1 minute, then 5, then 25, then 2 hours, then 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.written can arrive after a card.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.mailed is 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.