Countdown Timer API with Node.js

A complete client in about sixty lines with no dependencies, plus the three things a production integration needs and a first draft usually skips: retrying only what is worth retrying, handling update conflicts, and a webhook handler that verifies the signature against the raw body. Everything here runs on Node 18 or later.

The client

One module that knows the base URL, the auth header, and the response envelope. The typed error is worth the extra lines — it carries the code and request_id that make a failure diagnosable rather than mysterious.

countdownshare.ts

// countdownshare.ts — no dependencies, Node 18+.
const BASE = "https://countdownshare.com/api/v1";

export class CountdownError extends Error {
  constructor(
    message: string,
    readonly code: string,
    readonly status: number,
    readonly requestId: string,
    readonly retryAfter?: number,
  ) {
    super(message);
    this.name = "CountdownError";
  }
}

export type Timer = {
  id: string;
  name: string;
  type: "fixed" | "duration" | "recurring" | "personalized";
  status: "draft" | "published" | "archived";
  revision: number;
  remaining_seconds: number | null;
};

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(BASE + path, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.COUNTDOWNSHARE_API_KEY}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });

  const payload = await response.json();

  if (!response.ok) {
    throw new CountdownError(
      payload.error.message,
      payload.error.code,
      response.status,
      payload.request_id,
      Number(response.headers.get("Retry-After")) || undefined,
    );
  }

  return payload.data as T;
}
Capturing request_id on the error object is the single highest-value line here. It identifies the exact call in our logs, and it is the first thing support will ask for.

Retrying the right failures

Two error codes are worth retrying and the rest are not. A validation error will fail identically on every attempt, so retrying it wastes rate limit and delays the real error reaching your logs.

Retry with backoff

const RETRYABLE = new Set(["rate_limited", "internal_error"]);

/**
 * Retries only what is worth retrying.
 *
 * quota_exhausted is also a 429 but will NOT clear by waiting —
 * the monthly allowance resets with the billing cycle. Branch on
 * the code, never on the status alone.
 */
async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (error) {
      const retryable =
        error instanceof CountdownError && RETRYABLE.has(error.code);

      if (!retryable || attempt >= attempts - 1) throw error;

      const wait = error.retryAfter
        ? error.retryAfter * 1000
        : Math.min(2 ** attempt * 500, 8000);

      await new Promise((resolve) => setTimeout(resolve, wait));
    }
  }
}

The quota_exhausted distinction is the one that catches people. Both it and rate_limited return HTTP 429, but only one clears by waiting. See error codes for the full table of what is retryable.

Creating timers

Creation requires an Idempotency-Key. Derive it from the thing the timer represents so it stays identical across retries — a UUID generated at the call site defeats the whole mechanism.

Create and read

export function createTimer(body: object, idempotencyKey: string) {
  return withRetry(() =>
    request<Timer>("/timers", {
      method: "POST",
      headers: { "Idempotency-Key": idempotencyKey },
      body: JSON.stringify(body),
    }),
  );
}

export function getStatus(timerId: string) {
  return request<{
    status: string;
    ended: boolean;
    server_time: string;
    remaining: { total_seconds: number };
  }>(`/timers/${timerId}/status`);
}

// Usage: the key is derived from the order, so a retry after a
// timeout returns the original timer instead of a second one.
const timer = await createTimer(
  {
    name: `Cart hold ${order.id}`,
    type: "duration",
    duration_seconds: 900,
    metadata: { order_id: order.id },
  },
  `hold_${order.id}`,
);
The test to apply: if your process crashes and restarts, will it produce the same key for the same operation? hold_{order.id} survives that. crypto.randomUUID() does not, which is why a retry after a crash creates a duplicate. Idempotency has the full reasoning.

Updating without losing writes

PATCH requires the timer's current revision in an If-Match header. If something else wrote first, you get a 409 instead of silently overwriting their change.

Read, write, retry on conflict

/**
 * Updates use optimistic concurrency: send the revision you read,
 * and the API refuses the write if anything changed underneath.
 * On conflict, re-read and reapply — retrying the same revision
 * fails identically.
 */
export async function extendDeadline(timerId: string, deadlineAt: string) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const timer = await request<Timer>(`/timers/${timerId}`);

    try {
      return await request<Timer>(`/timers/${timerId}`, {
        method: "PATCH",
        headers: { "If-Match": String(timer.revision) },
        body: JSON.stringify({ deadline_at: deadlineAt }),
      });
    } catch (error) {
      if (
        !(error instanceof CountdownError) ||
        error.code !== "revision_conflict"
      ) {
        throw error;
      }
    }
  }

  throw new Error("Timer is being modified faster than we can update it");
}

Note that the retry re-reads before each attempt. Resending the same stale revision fails identically — the retry has to be of the operation, not the request.

Receiving webhooks in Express

Express webhook route

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();

// express.raw on THIS route only. A global express.json() consumes
// the stream and the signature can never be verified afterwards.
app.post(
  "/webhooks/countdownshare",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verify(req.body, req.headers, process.env.COUNTDOWNSHARE_WEBHOOK_SECRET!)) {
      return res.sendStatus(401);
    }

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

    // Acknowledge first — the budget is 10 seconds, and slow
    // handlers get retried.
    res.sendStatus(200);

    await queue.add("countdown-event", event, {
      // Delivery is at-least-once. Deduplicate on the event id.
      jobId: event.id,
    });
  },
);

function verify(raw: Buffer, headers: express.Request["headers"], secret: string) {
  const timestamp = headers["x-countdownshare-timestamp"] as string;
  const signature = headers["x-countdownshare-signature"] as string;
  if (!timestamp || !signature) return false;

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${raw.toString()}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature.replace(/^v1=/, ""), "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Three details that are load-bearing

express.raw on this route only, registered before any global JSON parser can reach it — once the body is parsed the original bytes are gone and the HMAC can never match. timingSafeEqual rather than ===, because a short-circuiting comparison leaks how many characters matched. And the 200 goes out before the work starts, because the handler has roughly ten seconds before the delivery is treated as a timeout and retried.

jobId: event.id in the queue call is doing quiet work. Delivery is at-least-once, so the same event can arrive twice after a retry or a manual replay — a job ID that matches the event ID makes the second one a no-op.

Common questions

Is there an official Node SDK?

No. There are no SDKs in any language — every example in the documentation is plain HTTP. The client on this page is about 60 lines and has no dependencies. If you would rather generate one, the OpenAPI 3.1 spec is public and unauthenticated.

Why does my Express webhook signature check always fail?

A global app.use(express.json()) has already consumed and parsed the body. Register express.raw({ type: "application/json" }) on the webhook route specifically, and register it before any JSON parser can reach that path. JSON.stringify of a parsed object is not the original bytes and never will be.

Should I retry a 429?

Only if the code is rate_limited, and then honour Retry-After. quota_exhausted is also a 429 but means the monthly timer allowance is spent, which will not clear until the billing cycle resets — retrying it just burns requests. Always branch on error.code rather than the status.

Do I need node-fetch?

Not on Node 18 or later — fetch is global. On older versions use undici or node-fetch; nothing else in the client changes.

Next steps

Paste it in and run it

Sandbox is free with any account. Set COUNTDOWNSHARE_API_KEY and every snippet on this page works unchanged.