Docs/API Reference

Webhooks API Reference

Subscribe to organisation events, verify signed payloads, and ship robust integrations. Search the docs below or jump to a section in the sidebar.

Overview

How AtTable webhooks work and what to expect.

AtTable posts a signed JSON payload to your endpoint when an event happens on your organisation (KYB approved/rejected, etc.). You verify the signature with the secret you saved when registering the endpoint, then act on the event.

  • Respond 2xx within 5 seconds to acknowledge. Anything else is treated as a failure and retried with exponential backoff.
  • 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 → Settings → 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

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": "Salesforce KYB sync (prod)",
  "eventTypes": ["org.verification_approved", "org.verification_rejected"]
}
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": "evt_01HXYZ…",                  // delivery id (idempotency key)
  "type": "org.verification_approved",  // event type
  "organisationId": "9f7…",             // your org id
  "createdAt": "2026-05-14T09:12:33Z",  // ISO 8601 UTC
  "data": { … }                         // event-specific body
}
Event typeWhen it fires`data` shape
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: "Test delivery", endpointId }
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 retried delivery 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, retries, 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.

Retry & failure policy

BehaviourValue
Connect timeout5s
Response body cap2 KB (we won't read more)
Successful status2xx
Retry onNetwork error, non-2xx, timeout
BackoffExponential (1m, 5m, 30m, 2h, 6h, …)
Auto-disable after20 consecutive failures

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, createdAt, data
                    }

Signing             HMAC_SHA256(secret, "<t>.<raw-body>") → hex
Tolerance           ±5 minutes
Ack window          5 seconds
Success             any 2xx
Auto-disable        20 consecutive failures

Ready to integrate?

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