OneTablet API Documentation

Verifying webhook signatures

A webhook delivery arrives at a public URL. Anyone who learns that URL — from a log, a proxy, a support ticket, a departing employee — can POST to it, and a body alone proves nothing about who sent it. The URL is not a secret and was never designed to be one.

So deliveries carry a signature, in a header whose value names the scheme that produced it:

X-OneTablet-Signature: v2=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...

v2 is the current scheme, and it is what you should implement. It is a standard JWT signed with a private key we hold and verified with a public key you fetch from us. Nothing we give you can produce a signature, so no integration — yours or anyone else's — holds material that could forge a delivery to you.

v1 is the older HMAC scheme, documented at the end of this page. It uses a shared secret, which means every party who can verify can also sign. If you are building a new integration, do not implement it.

Match on the prefix, never on the whole header. During a migration a delivery may carry both schemes at once, comma separated — v1=<hex>,v2=<jwt>. Split on ,, find the part whose prefix you implement, and ignore versions you do not.

Which scheme applies to which deliveries

DeliveriesScheme
Store events (store.status.changed and Phase 1 events)v2 — always signed, no opt-in
Phase 0 order events (the older delivery pipeline)Not signed yet — see *Scope* below

There is no unsigned mode on the store-event pipeline and no per-endpoint flag. If a delivery reaches you from it, it is signed.

The v2 scheme

What is signed

The JWT is a detached signature. It does not contain the event — your body is exactly the JSON envelope it has always been, byte for byte — it *binds* to it with a hash:

ClaimValue
isshttps://api.onetablet.com
iatwhen the delivery was signed, Unix seconds
nbfequal to iat
expiat + 300 — the replay window, as a standard claim
jtithe envelope's id, the same value you deduplicate on
body_sha256unpadded base64url SHA-256 of the raw request body

and in the JWT header:

HeaderValue
algES256 (ECDSA P-256 with SHA-256)
kidwhich of our published keys signed this delivery
typJWT

body_sha256 covers the raw body, not a re-serialization of it. Hash the exact bytes you received, before any JSON parse. If you parse the body and serialize it again, key order, whitespace and non-ASCII escaping can change, the hash changes with them, and *every* delivery fails verification — including the legitimate ones. This is the single most common way a webhook integration is misbuilt. Read the raw body first, verify, and parse only after.

Verifying the signature without comparing body_sha256 is not verifying the delivery. The token would then authenticate any body at all for the next five minutes.

The public keys

https://api.onetablet.com/.well-known/onetablet-webhook-jwks.json

A standard JWKS document. Hand that URL to your JWT library — createRemoteJWKSet in Node's jose, PyJWKClient in PyJWT, jwks-rsa, firebase/php-jwt's JWK::parseKeySet — and it resolves the token's kid to a key, caches the result, and re-fetches when it meets a kid it has not seen.

Do not copy a key out of that document into your configuration. The whole point of the URL is that key rotation costs you nothing: we publish the new key alongside the old one, switch which one we sign with, and your library follows on its own. A pinned copy is an outage scheduled for whenever we next rotate.

The document is cacheable for five minutes. Your library handles that; you do not need to.

The replay window

A delivery is valid for 300 seconds (5 minutes) from iat, expressed as nbf and exp — so your JWT library enforces it for you, in both directions, with no code of your own. That is the main practical difference from v1, where bounding the clock was your job and skipping it was invisible until someone replayed a captured delivery.

Allow about 60 seconds of clock tolerance (clockTolerance: 60 in jose) for ordinary NTP drift. Both our clock and yours should be NTP-synchronised; if your host's clock is minutes off, every delivery will look expired.

A delivery you reject on the clock is not lost — it is a failed attempt and is retried. What the window bounds is how long a *captured* delivery stays replayable.

A working Node example

Express, with the raw body preserved. The express.raw() middleware is the important part: the default express.json() parses and discards the bytes you need.

const crypto = require('node:crypto');
const express = require('express');
const { createRemoteJWKSet, jwtVerify } = require('jose');

const ONETABLET_JWKS_URL = 'https://api.onetablet.com/.well-known/onetablet-webhook-jwks.json';
const ONETABLET_ISSUER = 'https://api.onetablet.com';

// Build this ONCE, at startup. It owns the key cache — rebuilding it per request
// re-fetches the JWKS on every delivery.
const JWKS = createRemoteJWKSet(new URL(ONETABLET_JWKS_URL));

async function verifyDelivery(rawBody, signatureHeader) {
  // The header may carry more than one scheme during a migration. Take yours.
  const part = (signatureHeader || '')
    .split(',')
    .map((s) => s.trim())
    .find((s) => s.startsWith('v2='));
  if (!part) return null;

  // Signature, issuer and the nbf/exp replay window, all enforced by the library.
  const { payload } = await jwtVerify(part.slice('v2='.length), JWKS, {
    issuer: ONETABLET_ISSUER,
    algorithms: ['ES256'], // ALWAYS pin this; never trust the token's own alg
    clockTolerance: 60,
  });

  // The signature is DETACHED, so this is the step that ties it to the bytes you
  // received. Skipping it means a captured token authenticates any body.
  const expected = crypto.createHash('sha256').update(rawBody).digest('base64url');
  const claimed = Buffer.from(String(payload.body_sha256 || ''), 'utf8');
  const actual = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on a length mismatch, so check first.
  if (claimed.length !== actual.length) return null;
  if (!crypto.timingSafeEqual(claimed, actual)) return null;

  return payload;
}

const app = express();

app.post('/onetablet/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  let claims;
  try {
    claims = await verifyDelivery(req.body, req.get('X-OneTablet-Signature'));
  } catch {
    claims = null; // jose reports every failure as a throw
  }

  if (!claims) {
    // 401 is a failed attempt; it will be retried. That is the right answer —
    // it means a genuine delivery during a misconfiguration is not lost.
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8')); // parse only AFTER verifying

  // Acknowledge first, process asynchronously — you have 30 seconds.
  res.status(200).send();
  handleAsync(event);
});

The same shape works in any language: read raw bytes, verify the JWT against the JWKS with a pinned algorithm, then compare body_sha256 against a SHA-256 of those bytes.

algorithms: ['ES256'] is not optional. A verifier with no algorithm allow-list accepts whatever the *token* claims, which is the classic JWT break — an attacker re-signs with alg: HS256 using your copy of the public key as an HMAC secret, and a library that trusts the header agrees.

Key rotation

Nothing is required of you. We publish the new key in the JWKS, wait for caches to turn over, then start signing with it; your library sees an unfamiliar kid, re-fetches, and continues. Both keys stay published through the overlap, so deliveries already in flight under the old kid still verify.

The only way to be affected is to have pinned a key locally instead of using the URL.

Failure modes worth checking first

SymptomAlmost always
Every delivery fails on body_sha256You are hashing a re-serialized body. Capture the raw bytes before parsing.
Every delivery fails after working for weeksYou pinned a key instead of the JWKS URL, and we rotated.
Every delivery looks expiredYour host's clock is not NTP-synchronised, or you set no clockTolerance.
no applicable key found in the JSON Web Key SetYour JWKS cache is stale, or your library was built once per request and cannot cache.
Signature verifies, delivery still rejectedYou are not comparing body_sha256, or you compared it against a parsed body.
Header does not parseYou are matching the whole header instead of the v2= part. Split on ,.
RangeError from timingSafeEqualA length check is missing before the comparison.

Signing is not deduplication

A signature authenticates a delivery; it does not identify an event. Delivery is at-least-once by design, and the same event delivered twice is signed twice, seconds apart, with two different iats and therefore two different tokens. Deduplicate on the envelope's id — which is also the token's jti — as described in Webhooks, never on the signature.

Scope: which deliveries are signed

Signing is mandatory on every delivery from the store-event pipeline (store.status.changed and the Phase 1 events that follow it) — there is no opt-in, no per-endpoint flag, and no unsigned mode.

Phase 0 order events are delivered by the older pipeline and are not signed yet. Adding signatures there is a coordinated change: you must be verifying before we can start sending signatures you would otherwise reject, so it is scheduled with you rather than switched on. Until then, treat an order delivery the way this page's opening paragraph describes — require HTTPS, keep the URL unguessable, and treat organization_id as a sanity check rather than proof of origin.


Appendix: the legacy v1 scheme

v1 is HMAC-SHA256 under a shared secret issued with your endpoint. It remains supported and we will not remove it without arranging a migration with you, but it is not what new integrations should build against, for one structural reason: a shared secret that lets you *verify* also lets you *sign*. Every party holding it can mint deliveries indistinguishable from ours.

A v1 delivery carries two headers:

X-OneTablet-Timestamp: 1756213045
X-OneTablet-Signature: v1=3a1f...64 hex characters

where the signature is the lowercase hex HMAC-SHA256 of the signed payload — the timestamp, a single ., and the raw request body:

<X-OneTablet-Timestamp> + "." + <raw body bytes>

Two details carry the whole scheme, and both are things v2 moves out of your hands.

The raw body, not a re-serialization of it. Same rule, same failure mode as body_sha256 above.

The timestamp is inside the hash. It is not merely sent alongside. A timestamp not covered by the signature can be rewritten by whoever captured the delivery, which would make a captured order.created replayable forever. Reject any delivery whose X-OneTablet-Timestamp is more than 300 seconds from your current time, in either direction, *before* comparing the signature. Rejecting a future-dated timestamp matters as much as rejecting a stale one.

Compare in constant time (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP). A plain === returns as soon as two bytes differ, and the time it takes leaks how much of a guessed signature was correct.

const crypto = require('node:crypto');

// During a rotation this holds TWO values: add the new secret BEFORE we switch,
// remove the old one after the overlap. Swapping rather than adding drops every
// delivery signed in the moments around the switch.
const SIGNING_SECRETS = [
  process.env.ONETABLET_WEBHOOK_SECRET,
  process.env.ONETABLET_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

const REPLAY_WINDOW_SECONDS = 300;

function constantTimeEquals(a, b) {
  const left = Buffer.from(a, 'utf8');
  const right = Buffer.from(b, 'utf8');
  // timingSafeEqual throws on a length mismatch, so check first.
  if (left.length !== right.length) return false;
  return crypto.timingSafeEqual(left, right);
}

function isValidV1Delivery(rawBody, timestampHeader, signatureHeader) {
  const timestamp = Number(timestampHeader);
  if (!Number.isInteger(timestamp)) return false;

  // Check the clock BEFORE the HMAC, and in both directions.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return false;

  // Take the v1 part; the header may carry v2 alongside it during a migration.
  const part = (signatureHeader || '')
    .split(',')
    .map((s) => s.trim())
    .find((s) => s.startsWith('v1='));
  if (!part) return false;

  // rawBody is a Buffer — hash the bytes as received, never a re-serialization.
  const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), rawBody]);

  let matched = false;
  for (const secret of SIGNING_SECRETS) {
    const expected =
      'v1=' + crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
    // Deliberately not short-circuiting: the work done should not depend on
    // which secret matched.
    matched = constantTimeEquals(expected, part) || matched;
  }
  return matched;
}

Under v2 there is no secret to rotate on your side at all, and no clock arithmetic to get right. That is the reason to move.