Docs/API Reference

API Reference

Two directions: subscribe to signed webhooks and we push to you when a bill settles, or hold an API key and read orders and takings yourself. Search the docs below or jump to a section in the sidebar.

REST API (v1)

Read orders, line items and the accounting journal with an API key.

Everything under /api/v1 is authenticated by an API key alone — no session, no cookies. Create one at Dashboard → Integrations → API keys; a group issues keys for all its venues from its organisation settings instead.

http
GET /api/v1/me
Authorization: Bearer at_live_…
The token is shown once
We store only its SHA-256, so it cannot be shown again or recovered. Keys are revoked, never deleted — a revoked key keeps its prefix and its last-used time, which is what you need after a leak.

What a key can read

EndpointReturns
GET /api/v1/meWhat this key is, and every venue it reaches.
GET /api/v1/locationsThose venues, with currency and timezone.
GET /api/v1/ordersOrders, newest first, cursor-paginated. Filter by locationId, from/to, status, paymentStatus.
GET /api/v1/orders/:idOne order with its line items at the prices actually charged.
GET /api/v1/accounting/journalBalanced double-entry journals for a period, with a reconciliation against Revenue.

Four things that will bite otherwise

  • Follow nextCursor; never page by offset. The cursor is the last row’s position and is stable while the venue keeps trading. An offset is not: orders arriving mid-walk shift every later page, and the rows you lose are invisible — your totals just come out short.
  • Dates are the venue’s own days. from and to are interpreted in the timezone /locations reports, not UTC. Over a group you must name a locationId, because a date is not one instant across several clocks.
  • No guest personal data is returned, ever. An order says what was sold and how it was paid, never who ate it — the same promise the webhook payloads make.
  • A 404 means “not yours or not real”, and the two are deliberately indistinguishable. Nothing about other venues is discoverable here.
Plans still apply
The accounting journal is included from Pro upwards, checked per venue. A key for a Starter venue gets 402 — authentication is not entitlement.

Webhooks overview

How AtTable webhooks work and what to expect.

AtTable posts a signed JSON payload to your endpoint when something happens — a bill settles, a KYB submission is reviewed. You verify the signature with the secret you saved when registering the endpoint, then act on the event.

An endpoint belongs to one venue or to one organisation. A venue endpoint hears that venue; an organisation endpoint hears every venue in the group unless you name a subset. Either way restaurantId is on every venue event, so a receiver never has to work out which restaurant it is reading about.

  • Respond 2xx within 5 seconds to acknowledge. Anything else counts as a failed delivery.
  • We retry, so delivery is at-least-once. A timeout, a refused connection, a 5xx, a 408 or a 429 is tried again — up to 6 attempts with exponential backoff starting at 10 seconds. Your handler will occasionally see the same event twice: dedupe on AtTable-Event-Id, which is stable across every retry of one event.
  • Most 4xx are not retried. A 404 at a mistyped URL or a 401 from a receiver expecting a header we do not send will not improve by waiting, so we record it once and stop rather than filling your log with six identical rows.
  • After 20 consecutive failures the endpoint is auto-disabled. You can re-enable it from the dashboard.
  • Every delivery is identified by AtTable-Event-Id — use it as your idempotency key.

Register an endpoint

Create a webhook from the dashboard or API.

From the dashboard

  1. Open Dashboard → Integrations → Webhooks.
  2. Click Add endpoint.
  3. Paste your HTTPS URL, tick the event types you want, and save.
  4. Copy the signing secret immediately. It is shown once and never again. If you lose it, rotate via Reveal new secret.

From the API

One venue — this is the one most integrations want:

http
POST /api/outbound-webhooks/:restaurantId/endpoints
Authorization: Bearer <session token, or platform API key>
x-csrf-token: <double-submit cookie>
Content-Type: application/json

{
  "url": "https://api.acme.com/hooks/attable",
  "description": "Finance system (prod)",
  "events": ["order.settled"]
}

A whole group, from one endpoint. Add restaurantIds to limit it to some of the venues; leave it out and it hears every venue in the organisation, including ones you open later:

http
POST /api/orgs/:orgId/webhooks
Authorization: Bearer <session token, or platform API key>
x-csrf-token: <double-submit cookie>
Content-Type: application/json

{
  "url": "https://api.acme.com/hooks/attable",
  "description": "Group finance sync (prod)",
  "events": ["order.settled", "org.verification_approved"]
}
The secret is shown once
We only persist a tail (last 12 chars) plus the encrypted ciphertext. We cannot show the full secret again later — store it in your secret manager.

Event catalogue

Payload envelope and supported event types.

Every payload has the same envelope:

json
{
  "id": "5c2f…",                        // event id (idempotency key)
  "type": "order.settled",              // event type
  "organisationId": "9f7…" | null,      // the group, when the venue is in one
  "restaurantId": "3ab…" | null,        // the venue — always set on venue events
  "createdAt": "2026-05-14T09:12:33Z",  // ISO 8601 UTC
  "data": { … }                         // event-specific body
}
Event typeWhen it fires`data` shape
order.settledA bill was closed and paid for.{ sessionId, receiptCode, orderIds, totalCents, processedCents, recordedTenders, currency, settledAt, settlementMethod }
org.verification_approvedAdmin marks your KYB submission as approved.{ status, reviewedAt, legalName, countryCode }
org.verification_rejectedAdmin marks your KYB submission as rejected.{ status, reviewedAt, reason, legalName, countryCode }
webhook.testYou click Send test in the dashboard.{ message }
processedCents and recordedTenders are not the same money
processedCents is money AtTable moved — it has payment records and can be refunded through us. recordedTenders is money taken somewhere else: cash, the venue’s own card machine, a comp. A consumer that adds them together and offers a refund will eventually offer one for cash.
More event types are coming (member changes, plan changes, payout events). The envelope is stable — you can subscribe today and your handler keeps working as new fields land in data.

Verify the signature

HMAC-SHA256 verification in Node, Python, and Ruby.

Every request includes three headers:

HeaderValue
AtTable-EventThe event type (e.g. org.verification_approved)
AtTable-Event-IdThe delivery id — use as your idempotency key
AtTable-Signaturet=<unix-timestamp>,v1=<hex hmac>

The signature is HMAC_SHA256(secret, "<timestamp>.<raw-body>").

Sign the raw body
Sign the raw request body bytes, not a re-serialised JSON. If your framework parses JSON before you can see the raw body, re-serialisation will reorder keys and the HMAC will not match.

Node.js (Express)

typescript
import express from 'express';
import crypto from 'node:crypto';

const app = express();

app.post('/hooks/attable', express.raw({ type: 'application/json' }), (req, res) => {
  const sigHeader = req.header('AtTable-Signature') ?? '';
  const parts = Object.fromEntries(
    sigHeader.split(',').map((kv) => kv.split('=') as [string, string])
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return res.status(400).send('bad signature');

  // Reject events older than 5 minutes to defeat replays.
  if (Math.abs(Date.now() / 1000 - t) > 300) {
    return res.status(400).send('stale');
  }

  const expected = crypto
    .createHmac('sha256', process.env.ATTABLE_WEBHOOK_SECRET!)
    .update(`${t}.${req.body.toString('utf8')}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
    return res.status(401).send('bad sig');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // … handle event …
  return res.status(200).send('ok');
});

Python (FastAPI)

python
import hmac, hashlib, os, time
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()

@app.post("/hooks/attable")
async def attable_hook(
    request: Request,
    attable_signature: str = Header(...),
):
    parts = dict(p.split("=", 1) for p in attable_signature.split(","))
    t, v1 = int(parts["t"]), parts["v1"]
    if abs(time.time() - t) > 300:
        raise HTTPException(400, "stale")

    raw = await request.body()
    expected = hmac.new(
        os.environ["ATTABLE_WEBHOOK_SECRET"].encode(),
        f"{t}.{raw.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise HTTPException(401, "bad sig")

    event = await request.json()
    # … handle event …
    return {"ok": True}

Ruby (Sinatra)

ruby
require 'sinatra'
require 'openssl'
require 'json'

post '/hooks/attable' do
  sig = request.env['HTTP_ATTABLE_SIGNATURE'] || ''
  parts = sig.split(',').to_h { |kv| kv.split('=', 2) }
  t = parts['t'].to_i
  v1 = parts['v1']
  halt 400, 'stale' if (Time.now.to_i - t).abs > 300

  body = request.body.read
  expected = OpenSSL::HMAC.hexdigest(
    'SHA256', ENV['ATTABLE_WEBHOOK_SECRET'], "#{t}.#{body}"
  )
  halt 401, 'bad sig' unless Rack::Utils.secure_compare(expected, v1)

  event = JSON.parse(body)
  # … handle event …
  status 200
end

Recipes

Common integration patterns: billing, CRM, fan-out.

Billing — provision when KYB is approved

When an org's KYB passes, flip them from “pending” to “active” in your downstream billing/ledger system, generate a customer record, and unlock production usage.

Subscribe to: org.verification_approved

typescript
async function handleApproved(event: VerificationApprovedEvent) {
  const { organisationId, data } = event;

  // 1. Find or create the customer in your billing tool.
  const customer = await ourBilling.customers.upsert({
    externalId: organisationId,
    name: data.legalName,
    country: data.countryCode,
    status: 'active',
  });

  // 2. Provision the entitlements your product gates on.
  await entitlements.grant(customer.id, ['production-api', 'high-volume-tier']);

  // 3. Notify the AM in Slack.
  await slack.send(
    '#sales',
    `:white_check_mark: ${data.legalName} approved — provisioned in billing.`
  );
}
Idempotency
Index your attable_events table on event.id so a redelivered event doesn't double-provision.

CRM — pipe rejections into Salesforce

Subscribe to: org.verification_rejected

typescript
async function handleRejected(event: VerificationRejectedEvent) {
  const { organisationId, data } = event;

  await salesforce.records.update('Account', {
    AtTableOrgId__c: organisationId,
    KYB_Status__c: 'Rejected',
    KYB_Rejection_Reason__c: data.reason,
    KYB_Reviewed_At__c: data.reviewedAt,
  });

  await salesforce.tasks.create({
    Subject: `KYB rejected — follow up with ${data.legalName}`,
    Description: data.reason,
    WhatId: 'AtTableOrgId__c=' + organisationId,
    OwnerId: '$lookup:AccountManager',
    Priority: 'High',
  });
}

Custom — replace your cron poller

If you're currently running a cron that polls GET /api/orgs/:id/... every N minutes to detect state changes, replace it with a webhook. You get a signed HTTPS POST the moment the event happens — sub-second latency, no quota churn, no missed-window bugs.

  • Data warehouse: push every event into BigQuery / Snowflake via Fivetran's HTTP source.
  • Status pages: flip your internal “AtTable up?” indicator when webhook.test arrives successfully.
  • Notification fan-out: receive once, fan out to Slack + email + PagerDuty.

Skeleton handler

typescript
const handlers: Record<string, (e: AtTableEvent) => Promise<void>> = {
  'org.verification_approved': handleApproved,
  'org.verification_rejected': handleRejected,
  'webhook.test': async () => { /* no-op, just 200 */ },
};

app.post('/hooks/attable', verifySignature, async (req, res) => {
  const event = JSON.parse(req.body.toString('utf8')) as AtTableEvent;

  // De-dupe — same delivery id may arrive twice if our ack was lost.
  if (await seenEvents.has(event.id)) return res.status(200).send('dup');
  await seenEvents.add(event.id, { ttlSeconds: 24 * 60 * 60 });

  const handler = handlers[event.type];
  if (handler) await handler(event);
  return res.status(200).send('ok');
});

Operational notes

Acknowledgement, delivery guarantees, replay protection, SSRF.

Acknowledge fast

You have 5 seconds to return a 2xx. If your handler does heavy work, push the event onto a queue inside the HTTP handler and return 200 immediately, then process async.

Delivery & failure policy

BehaviourValue
Connect timeout5s
Response body cap2 KB (we won't read more)
Successful status2xx
Delivery guaranteeAt-least-once — dedupe on the event id
Attempts6, exponential backoff from 10s
RetriedNetwork error, timeout, 5xx, 408, 429
Not retriedOther 4xx — a 404 or 401 does not improve by waiting
Counts as failedAnything that is not a 2xx, redirects included
Auto-disable after20 consecutive failures

Every attempt is a row in the delivery log, so a retried event shows up several times with one event id — that is the view to open when a receiver is flaky. After six attempts the event is given up on, so reconcile anything you cannot afford to miss against your own state rather than assuming the stream is complete.

Replay protection

Reject events whose timestamp (t from the signature header) is more than 5 minutes away from your server clock. Make sure your servers run NTP.

Idempotency

AtTable-Event-Id (also available as event.id) is the canonical idempotency key. Store it for at least 24 hours.

Don't expose internal services

We resolve your URL's hostname on every delivery and refuse to connect to RFC1918 / loopback / link-local IPs (127.0.0.0/8, 10/8, 192.168/16, etc.) even if your DNS resolves to them. This is an anti-SSRF measure and is not configurable.

Rotate secrets

If a secret is leaked, click Reveal new secret in the dashboard. The old key is invalidated immediately — deploy the new one before rotating in production.

Why isn't my endpoint receiving?

  1. Disabled? Red badge in the dashboard means we auto-disabled after 20 consecutive failures.
  2. Wrong event types? The subscription is a positive allow-list.
  3. Signature mismatch? Use Send test — it shows the exact body and headers so you can repro the HMAC locally.
  4. Hostname resolving to a private IP? The Delivery log says private host.
  5. Slow handler? Anything over 5s counts as a timeout.

Quick reference

Headers, signing, ack window at a glance.

text
Header              Format
─────────────────────────────────────────────────────────────────
AtTable-Event       <event-type>
AtTable-Event-Id    <delivery-id>            # idempotency key
AtTable-Signature   t=<unix>,v1=<hex-hmac>   # HMAC over "<t>.<raw-body>"

Body                {
                      id, type, organisationId, restaurantId,
                      createdAt, data
                    }

Signing             HMAC_SHA256(secret, "<t>.<raw-body>") → hex
Tolerance           ±5 minutes
Ack window          5 seconds
Success             any 2xx
Delivery            at-least-once — dedupe on AtTable-Event-Id
Retries             6 attempts, exponential backoff from 10s
Retried on          network error, timeout, 5xx, 408, 429
Auto-disable        20 consecutive failures

Ready to integrate?

Register your first webhook from the dashboard, or contact us for help.