HotCodePush

Signature and retries

How to verify the HotCodePush-Signature header and deduplicate deliveries, when a failed delivery is retried and when a webhook is disabled.

A raw delivery carries HotCodePush-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of "<t>.<body>", the timestamp, a dot and the raw request body, keyed with the webhook's secret. Verify it before you parse the body, and answer 401 to a request that fails.

Three details make the check correct:

  • Compute the HMAC over the body exactly as received, not re-serialized.
  • Refuse a timestamp more than 5 minutes from your clock, which bounds a replay.
  • Compare the two values in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const secret = process.env.HOTCODEPUSH_WEBHOOK_SECRET!;
const toleranceSeconds = 5 * 60;

function isSignatureValid(header: string | undefined, body: string): boolean {
  const fields = new Map((header ?? "").split(",").map((field) => field.split("=") as [string, string]));
  const timestamp = Number(fields.get("t"));
  const signature = fields.get("v1") ?? "";
  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

createServer((request, response) => {
  let body = "";
  request.on("data", (chunk) => (body += chunk));
  request.on("end", () => {
    if (!isSignatureValid(request.headers["hotcodepush-signature"] as string | undefined, body)) {
      response.writeHead(401).end();
      return;
    }
    const event = JSON.parse(body);
    console.log(request.headers["hotcodepush-delivery-id"], event.type, event.id);
    response.writeHead(204).end();
  });
}).listen(3000);

Deduplication and ordering

Every delivery, in every format, carries HotCodePush-Delivery-Id, which stays the same across the retries of one delivery and across a redelivery. The payload's id is the event's id, the same across every webhook that receives the event. Keep the ids you have handled and skip a repeat.

Every delivery also sends User-Agent: HotCodePush-Webhooks/1.0 (+https://hotcodepush.com/docs/webhooks). Allow that value when a firewall sits in front of your endpoint.

Deliveries can arrive out of order: each one is retried on its own, so a release.paused can land before the release.created of the same release. The snapshot carries the object's state at the moment of the event. A receiver that needs the current state reads it from the API reference by objectId.

Retries

Each attempt waits 10 seconds for your answer. Any 2xx status is success, and every other status, a timeout and a refused connection count as a failure. A failed attempt is sent again on this schedule, 6 attempts over about half a day:

AttemptSent after
21 minute
35 minutes
430 minutes
52 hours
68 hours

After the sixth failed attempt the delivery is failed for good and stays in the delivery log for a redelivery. Answer 2xx as soon as you have stored the event, and do the work afterwards.

Auto-disable

Every delivery that fails its last attempt lengthens the webhook's streak, consecutiveFailureCount, and a success resets it to 0. At 50 the webhook is disabled: isEnabled turns off, the audit log records webhook.disabled, and the organization's members get a mail. A disabled webhook receives nothing, and a delivery already pending for it becomes failed without a request.

The Enable action in the webhook's Actions menu, hotcodepush webhook update or an update with isEnabled: true switches it on again and resets the streak. Events from while it was off are not delivered afterwards, but they stay in the audit log.

Last updated on

On this page