Skip to main content
Tally delivers real-time HTTP notifications to URLs you register whenever key events happen on your tenant’s subjects. Rather than polling the API for changes, you register a webhook subscription once and your endpoint receives a signed POST request each time a matching event fires. Each subscription is scoped to a tenant and one or more event types, and every delivery is signed with an HMAC secret so you can verify that the payload genuinely came from Tally.

Supported events

Event typeWhen it fires
identity.snapshot.createdA new snapshot is created for any subject owned by your tenant (applies whether the snapshot was submitted directly or via the propose/apply flow)
refresh_request.createdA counterparty tenant creates a refresh request targeting a subject your tenant owns
refresh_request.fulfilledA refresh request that your tenant created is fulfilled by the subject owner

Create a subscription

POST /v1/webhook-subscriptions Your principal must be an active member of the tenant_id you specify, with at least the tenant_reader role.
curl -X POST https://api.tally.io/v1/webhook-subscriptions \
  -H "Authorization: Bearer $TALLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "acme-kyc",
    "target_url": "https://hooks.yourapp.com/tally",
    "event_types": [
      "identity.snapshot.created",
      "refresh_request.created",
      "refresh_request.fulfilled"
    ]
  }'

Request body fields

FieldRequiredDescription
tenant_idYesYour tenant ID — events for this tenant will be delivered to the subscription
target_urlYesThe HTTPS URL Tally should POST event payloads to
event_typesYesNon-empty array of event type strings from the supported list above
The 201 Created response includes both the subscription record and a secret. Save the secret immediately — it is only returned at creation time and cannot be retrieved again.
{
  "webhook_subscription": {
    "subscription_id": "wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "tenant_id": "acme-kyc",
    "target_url": "https://hooks.yourapp.com/tally",
    "status": "active",
    "event_types": [
      "identity.snapshot.created",
      "refresh_request.created",
      "refresh_request.fulfilled"
    ],
    "secret_last_rotated_at": "2026-03-01T12:00:00Z",
    "disabled_at": null,
    "created_at": "2026-03-01T12:00:00Z"
  },
  "secret": "whsec_4a8f3c2b1e9d7a6f5c4b3e2d1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2"
}

Verifying webhook payloads

Tally signs every webhook delivery by computing HMAC-SHA256(secret, raw_request_body) and sending the result as the X-Tally-Signature header. You must verify this signature before processing any payload to ensure it was not tampered with in transit.
import crypto from 'crypto';

function verifySignature(secret: string, body: string, signature: string): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Use this in your webhook handler before any other processing:
import express from 'express';

const app = express();

app.post('/tally', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-tally-signature'] as string;
  const rawBody = req.body.toString('utf8');

  if (!verifySignature(process.env.TALLY_WEBHOOK_SECRET!, rawBody, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(rawBody);
  console.log('Received event:', event.type);

  // Handle the event...
  res.status(200).send('ok');
});
Use crypto.timingSafeEqual for the comparison — never use === or other string equality checks. Non-constant-time comparisons are vulnerable to timing attacks that could allow an attacker to forge valid signatures.

Manage subscriptions

Retrieve all subscriptions for a tenant:
curl "https://api.tally.io/v1/webhook-subscriptions?tenant_id=acme-kyc" \
  -H "Authorization: Bearer $TALLY_API_KEY"
{
  "items": [
    {
      "subscription_id": "wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "tenant_id": "acme-kyc",
      "target_url": "https://hooks.yourapp.com/tally",
      "status": "active",
      "event_types": ["identity.snapshot.created"],
      "secret_last_rotated_at": "2026-03-01T12:00:00Z",
      "disabled_at": null,
      "created_at": "2026-03-01T12:00:00Z"
    }
  ]
}

Rotate your signing secret

If your secret is compromised, or as part of a regular credential rotation schedule, call POST /v1/webhook-subscriptions/:subscription_id/rotate-secret to issue a new secret immediately.
curl -X POST https://api.tally.io/v1/webhook-subscriptions/wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890/rotate-secret \
  -H "Authorization: Bearer $TALLY_API_KEY"
The response includes the new secret alongside the updated subscription record:
{
  "webhook_subscription": {
    "subscription_id": "wsub_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "tenant_id": "acme-kyc",
    "target_url": "https://hooks.yourapp.com/tally",
    "status": "active",
    "event_types": ["identity.snapshot.created"],
    "secret_last_rotated_at": "2026-06-15T08:00:00Z",
    "disabled_at": null,
    "created_at": "2026-03-01T12:00:00Z"
  },
  "secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e"
}
Update your TALLY_WEBHOOK_SECRET environment variable (or secret store) with the new value immediately. Tally switches to signing with the new secret right away — deliveries that arrive after the rotation will use the new secret, so update your verification logic before the rotation response arrives or accept a brief window where signature checks may fail.
The plaintext secret is only returned at creation time and at rotation time. Tally stores a hashed and encrypted version of the secret internally and cannot retrieve or display the original value after those moments. If you lose your secret, rotate it immediately to obtain a new one.