PostAtom docs

Webhooks

PostAtom tells you what happened rather than making you ask. A delivery carries the event, a timestamp and a signature over both, and your receiver verifies it before trusting a word of it.

The signature

Every delivery carries one header. The signature covers the timestamp as well as the body, which is what makes a captured delivery useless tomorrow: a signature over the body alone stays valid for ever, so anyone who records one delivery can replay it whenever they like and you cannot tell.

header
PostNode-Signature: v1,t=1789273244,s=3f8c…

Verification has three rules, and every hand-written receiver gets one of them wrong: compare the digest in constant time rather than with ===, refuse a timestamp outside a narrow window, and accept both secrets during a rotation. The examples below do all three with nothing but each language’s standard library.

Verify a webhook (TypeScript)

Verify before you trust. The signature covers the timestamp as well as the body, so a captured delivery is useless tomorrow.

typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(header: string | undefined, raw: string, secrets: string[]) {
  const match = /^v1,t=(\d{1,15}),s=([a-f0-9]{64})$/.exec(header?.trim() ?? '');
  if (!match) return false;
  const timestamp = Number(match[1]);
  // Five minutes either way: an old delivery is as suspect as one from the future.
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const supplied = Buffer.from(match[2], 'hex');
  let verified = false;
  for (const secret of secrets) {
    const expected = createHmac('sha256', secret)
      .update(`v1.${timestamp}.${raw}`)
      .digest();
    // Every secret is compared, in constant time.
    if (timingSafeEqual(expected, supplied)) verified = true;
  }
  return verified;
}

app.post('/postnode', async (request, response) => {
  // The raw body, exactly as sent. A parsed and re-serialized body will not verify.
  const raw = await readRawBody(request);
  const secrets = [process.env.POSTNODE_WEBHOOK_SECRET!];
  if (!verify(request.headers['postnode-signature'], raw, secrets))
    return response.status(400).end();

  const event = JSON.parse(raw);
  console.log(event.type, event.data);
  response.status(204).end();
});

Verify a webhook (Python)

Same three rules everywhere: compare in constant time, bind the timestamp, accept both secrets during a rotation.

python
import hashlib, hmac, json, os, re, time

def verify(header, raw, secrets):
    match = re.fullmatch(r"v1,t=(\d{1,15}),s=([a-f0-9]{64})", (header or "").strip())
    if not match or abs(time.time() - int(match[1])) > 300:
        return False
    signed = f"v1.{match[1]}.".encode() + raw
    expected = [hmac.new(s.encode(), signed, hashlib.sha256).hexdigest() for s in secrets]
    return any([hmac.compare_digest(e, match[2]) for e in expected])

@app.post("/postnode")
async def receive(request):
    raw = await request.body()
    secrets = [os.environ["POSTNODE_WEBHOOK_SECRET"]]
    if not verify(request.headers.get("PostNode-Signature"), raw, secrets):
        return Response(status_code=400)
    event = json.loads(raw)
    return Response(status_code=204)

Verify a webhook (Go)

go
var signature = regexp.MustCompile(`^v1,t=(\d{1,15}),s=([a-f0-9]{64})$`)

func verify(header string, raw []byte, secrets []string) bool {
    parts := signature.FindStringSubmatch(strings.TrimSpace(header))
    if parts == nil {
        return false
    }
    timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
    if age := time.Now().Unix() - timestamp; age > 300 || age < -300 {
        return false
    }
    supplied, _ := hex.DecodeString(parts[2])
    verified := false
    for _, secret := range secrets {
        mac := hmac.New(sha256.New, []byte(secret))
        fmt.Fprintf(mac, "v1.%d.", timestamp)
        mac.Write(raw)
        if hmac.Equal(mac.Sum(nil), supplied) {
            verified = true
        }
    }
    return verified
}

Rotating a secret

Rotation issues a new secret and keeps the previous one valid for twenty-four hours. Verify against both for that window: verifying against only the new one makes every rotation a brief outage for whoever has not redeployed yet.

Rotate a webhook secret without an outage

Accept both for the overlap. Verifying against only the new secret makes every rotation a brief outage for anyone who has not redeployed.

typescript
verify(header, raw, [
  process.env.POSTNODE_WEBHOOK_SECRET!,
  process.env.POSTNODE_WEBHOOK_SECRET_PREVIOUS!,
]);

When a delivery fails

A delivery that does not return a 2xx is retried on a widening schedule. The whole sequence spans about two days, which is long enough for a receiver to be repaired without PostAtom giving up on the event.

  1. 110 seconds
  2. 21 minute
  3. 35 minutes
  4. 415 minutes
  5. 51 hour
  6. 63 hours
  7. 76 hours
  8. 812 hours
  9. 924 hours

After the last attempt the endpoint is marked as failing and stops receiving until you re-enable it. Your receiver should be idempotent: a delivery that succeeded but whose response was lost is retried, and the same event arriving twice must not do the work twice.

What a receiver must do

  • Read the raw body. A body that has been parsed and re-serialized will not verify — key order and whitespace are part of what was signed.
  • Answer quickly. Acknowledge with a 2xx and do the work afterwards. A slow receiver becomes a failing one.
  • Treat the event as a notification, not as truth. If the decision matters, read the post back through the API.

Every page here is generated from the code that runs. If a number on this site disagrees with the API, the build fails before it ships.