Skip to content
PacSpace
Talk to us

Signature verification

Every delivery is signed with your endpoint's secret. Check the signature and the clock before you act on the body, with the SDK or with a few lines of your own.

PacSpace signs every delivery with the secret shown when you made the endpoint. Checking it is how your handler knows the body came from PacSpace and was not changed on the way. Check before you act, and check the raw body as received, before any JSON parsing, because a re-serialized body will not match.

What is signed

text
{timestamp}.{body}

timestamp is the value of the X-PacSpace-Timestamp header, Unix time in milliseconds, and body is the request body exactly as sent. The signature is an HMAC with SHA-256 over that string, keyed with your secret, hex encoded, and sent as X-PacSpace-Signature: v1=<hex>.

For 24 hours after you rotate the secret with overlap, the header carries two values, comma separated, one per secret. The delivery passes when either matches the secret you hold.

With the SDK

The handler below takes deliveries for an evaluation harness. It needs the endpoint's signing secret and nothing else.

typescript
import { Webhooks, WebhookVerificationError } from '@pacspace-io/sdk';
import type { RecordCommittedPayload, RecordFailedPayload } from '@pacspace-io/sdk';

const webhooks = new Webhooks(process.env.PACSPACE_WEBHOOK_SECRET!);

// rawBody is the request body as received, as a string.
export function handle(headers: Record<string, string | string[] | undefined>, rawBody: string) {
  let event;
  try {
    event = webhooks.verifyFromHeaders(headers, rawBody);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return { status: 401 };
    throw err;
  }
  if (event.event === 'record.committed') {
    const data = event.data as RecordCommittedPayload;
    for (const entry of data.records) {
      // entry.entityId is the record, such as eval-run-4417; entry.seq + 1 is the entry number
    }
  } else if (event.event === 'record.failed') {
    const data = event.data as RecordFailedPayload;
    // data.code and data.sentence name the rule the entry did not meet
  }
  return { status: 200 };
}

Both refuse a delivery whose timestamp is more than five minutes from your clock, to stop a captured delivery being replayed later; pass tolerance in seconds to change the window ({ tolerance: 600 } in TypeScript, tolerance=600 in Python). A PacSpace client made with webhookSecret (webhook_secret in Python) has the same methods on pac.webhooks; without a secret that field is not set.

With an Express-style server, webhooks.middleware() does the check, puts the event on req.pacspaceEvent, and answers 401 itself on a bad signature. It reads the body from req.rawBody, so keep a copy there before the route runs:

typescript
app.use('/webhooks/pacspace', express.json({
  verify: (req, _res, buf) => { (req as any).rawBody = buf.toString(); },
}));
app.post('/webhooks/pacspace', webhooks.middleware(), (req, res) => res.sendStatus(200));

Without the SDK

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

export function isSignedByPacSpace(headers, rawBody, secret, toleranceSeconds = 300) {
  const timestamp = headers['x-pacspace-timestamp'];
  const header = headers['x-pacspace-signature'];
  if (!timestamp || !header) return false;
  if (Math.abs(Date.now() - Number(timestamp)) > toleranceSeconds * 1000) return false;
  const expected = Buffer.from('v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'));
  return header.split(',').map((s) => s.trim()).some((candidate) => {
    const received = Buffer.from(candidate);
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

Both check the clock first: the timestamp is in milliseconds, and a delivery more than five minutes from now is refused.

Getting the raw body

Most frameworks parse JSON before your handler runs. You need the bytes as received. In Express, keep a copy in the verify callback of express.json, as above, or read the route with express.raw({ type: 'application/json' }) and pass req.body.toString(); in Fastify, add a content type parser that keeps the raw string; in Django, use request.body.decode(); in Flask, request.get_data(as_text=True). Verify that string, then parse it.

If verification fails

Answer 401 and do nothing with the body. A failed verification means one of four things: the secret in your code is not the one on the endpoint (the secret is shown once, so rotate it and use the new one), the body was re-serialized before the check, your clock is more than five minutes off, or the delivery did not come from PacSpace. Sandbox and Production endpoints have different secrets, so check that the secret matches the environment. PacSpace treats a 401 as a failed attempt and retries on the schedule, so a fix on your side picks up the missed deliveries.