Webhook events and payload

Every delivery is a JSON POST carrying three signature headers and a body describing what happened. There is one detail worth knowing before you write the handler: the type in the outbound body is timer.rule.matched for anything a rule produced, not the underlying event name. What actually happened is on the delivery record, and the rule that fired is in the body.

Anatomy of a delivery

Headers

POST /webhooks/countdownshare HTTP/1.1
Content-Type: application/json
X-CountdownShare-Event-Id: d59cf3d3-a20c-46bf-b187-c27cd6ff51e2
X-CountdownShare-Timestamp: 1893506400
X-CountdownShare-Signature: v1=9f86d0818...

Body

{
  "id": "d59cf3d3-a20c-46bf-b187-c27cd6ff51e2",
  "type": "timer.rule.matched",
  "occurred_at": "2030-01-01T13:00:00.000Z",
  "delivery_id": "0ec5af80-1125-43c8-9b72-04706b6da54a",
  "timer": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Product launch",
    "type": "fixed",
    "status": "published"
  },
  "rule": {
    "id": "264f4933-c839-47b5-a7ba-55a73ed56774",
    "name": "One hour remaining"
  },
  "data": {
    "status": "running",
    "remaining_seconds": 3600,
    "progress_percent": 99.86,
    "server_time": "2030-01-01T13:00:00.000Z"
  }
}
HeaderPurpose
X-CountdownShare-Event-IdThe event ID. Stable across retries and replays — deduplicate on this.
X-CountdownShare-TimestampUnix seconds. Part of the signed string, and used to reject stale deliveries.
X-CountdownShare-Signaturev1=<hex>. HMAC-SHA256 of "{timestamp}.{raw_body}" using the destination signing secret.

The body, field by field

FieldTypeDescription
iduuidThe event ID. Matches the X-CountdownShare-Event-Id header. Your deduplication key.
typestringtimer.rule.matched for any rule-produced delivery. See the note below.
occurred_atstringISO 8601 UTC — when the event happened, not when we sent it.
delivery_iduuidThis specific delivery. Changes on a replay; the event id does not.
timer.id / name / type / statusobjectEnough about the timer to act without a follow-up read.
rule.id / nameobjectWhich rule fired. Branch on the name.
data.statusstringThe live clock state at the moment the rule matched.
data.remaining_secondsintegerSeconds left when it matched.
data.progress_percentnumberHow far through, 0 to 100.
data.server_timestringOur clock at the moment of the match.
id and delivery_id are different on purpose. id identifies the thing that happened and stays constant through every retry and manual replay — so it is the correct deduplication key. delivery_id identifies one attempt to tell you about it, and is what you pass to the replay endpoint.

Event types

These are the domain events recorded against a timer and stored on the delivery record.

EventFires when
timer.createdA timer is created.
timer.updatedAny field on a timer changes.
timer.deletedA timer is permanently deleted.
timer.duplicatedA timer is copied.
timer.archivedA timer is moved out of the active set.
timer.restoredAn archived timer is brought back.
timer.startedA duration timer is started.
timer.pausedA duration timer is paused.
timer.resumedA paused duration timer resumes.
timer.resetA duration timer is reset to its full duration.
timer.completedThe countdown reaches zero. The event most integrations care about.
timer.recurrence_completedA recurring cycle finishes and the timer advances.

Why the outbound type is always timer.rule.matched

A rule is what caused us to call you, and a rule can match on things that are not themselves discrete events — “an hour remaining” is a threshold being crossed, not an action anyone took. Reporting a single, honest type keeps the body meaning one thing rather than sometimes meaning two.

So: branch on rule.name, which you chose, or on data.status, which describes the timer. If you need the underlying event type, read the delivery record with GET /webhook-deliveries/{delivery_id} — it carries event_type.

A handler that will not surprise you

Express

app.post(
  "/webhooks/countdownshare",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verify(req.body, req.headers, SECRET)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString());

    // Acknowledge first. Everything below 10 seconds is a timeout.
    res.sendStatus(200);

    // Branch on the rule name, not on the numbers. The rule already
    // decided the threshold; re-deriving it here is a second place
    // to get it wrong.
    switch (event.rule.name) {
      case "One hour remaining":
        await queue.add("send-reminder", { timerId: event.timer.id });
        break;

      case "Ended":
        await queue.add("release-reservation", { timerId: event.timer.id });
        break;

      default:
        // An unrecognised rule is not an error — someone may have
        // added one in the dashboard. Log it and move on.
        logger.info("Unhandled rule", { rule: event.rule.name });
    }
  },
);

Three things are load-bearing here. express.raw keeps the body as bytes so the signature can be verified — parse it first and verification will fail. The 200 goes out before the work starts, because the budget is ten seconds. And an unrecognised rule name is logged rather than thrown, so somebody adding a rule in the dashboard does not take your endpoint down.

Full verification code, and the mistakes that break it, are on verify a delivery. Do not put a handler in production without it — an unverified endpoint acts on anything anyone posts to it.

Handling duplicates

Delivery is at-least-once. A retry after a timeout your service caused, or a manual replay, both deliver the same event ID again. If your handler charges a card or sends an email, processing it twice is a real problem.

Deduplicate on the event id

// Delivery is at-least-once. The same event id can arrive twice.
async function handleOnce(event) {
  // A unique constraint on event_id makes this atomic and cheap.
  const inserted = await db.processedEvents.insertIfAbsent({
    eventId: event.id,
    receivedAt: new Date(),
  });

  if (!inserted) return;   // already handled — nothing to do

  await doTheActualWork(event);
}

A unique constraint on the event ID does this atomically and does not need a lock. Keep the records for a few days — retries span roughly fifteen hours, and manual replays can come later than that.

Where you can, make the work naturally idempotent instead — “set the reservation to released” is safe to repeat in a way that “add stock back” is not. Then deduplication becomes an optimisation rather than a correctness requirement.