Skip to main content
When data changes in a Salty workspace, registered webhook endpoints receive a signed POST with the new state. A background worker on the API process polls every 2s, fires pending deliveries, and retries failures with an exponential backoff. After 8 consecutive failures the endpoint is marked inactive and a webhook_endpoint.failed meta-event fans out to any other still-active endpoints in the same workspace.

Register an endpoint

curl -X POST $SALTY_API/webhook-endpoints \
  -H "Authorization: Bearer $SALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/salty-webhooks",
    "subscribed_events": ["*"]
  }'
Response (the signing_secret is shown once; store it):
{
  "id": "e0f7ff39-9e17-4210-ad6c-57fa091eddd3",
  "url": "https://your-app.example.com/salty-webhooks",
  "subscribed_events": ["*"],
  "is_active": true,
  "created_at": "2026-05-24T12:00:00Z",
  "signing_secret": "whsec_rMvR4H8G4t80BxnImJ..."
}
Subsequent reads of the endpoint (GET /webhook-endpoints or GET /webhook-endpoints/:id) omit signing_secret.

Signature

Every delivery carries:
Salty-Signature: t=1779587687,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verify by computing HMAC-SHA-256 over <timestamp>.<body> with your whsec_… secret, then compare with constant-time equality. Reject deliveries older than 5 minutes to prevent replay.

Verifier (Node)

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

export function verify(payload: string, header: string, secret: string, toleranceMs = 5 * 60_000): boolean {
  const parts = Object.fromEntries(header.split(',').map((s) => s.split('=').map((x) => x.trim())));
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() - t) > toleranceMs) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${payload}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Payload shape

{
  "event": "person.created",
  "data": {
    "id": "b5a6d70e-483e-4826-ad66-99546ea4e102",
    "email": "jane@acme.com",
    "first_name": "Jane",
    "last_name": null,
    "primary_company_id": null,
    "custom_attributes": {},
    "created_at": "2026-05-24T12:00:00Z",
    "updated_at": "2026-05-24T12:00:00Z"
  },
  "delivery_id": "1125fdc6-aaaa-bbbb-cccc-dddddddddddd"
}
delivery_id is unique per delivery — use it for idempotent receiver-side processing (the same event may be re-delivered after a transient receiver failure).

Retry schedule

1s → 5s → 30s → 5min → 30min → 2h → 12h between attempts. After the 8th attempt fails, the delivery is marked failed, the endpoint flips is_active = false, and a webhook_endpoint.failed meta-event fans out to other still-active endpoints in the workspace:
{
  "event": "webhook_endpoint.failed",
  "data": {
    "endpoint_id": "bc31e811-…",
    "url": "https://broken-receiver.example.com/",
    "failed_delivery_id": "…",
    "last_response_status": 500
  },
  "delivery_id": "…"
}
To re-enable a deactivated endpoint, delete it and create a new one. (v1.1 will add a re-enable endpoint.)

Events

ObjectEvents
person, company, deal.created, .updated, .deleted
note, task, activity.created, .updated, .deleted
custom_object_record.created, .updated, .deleted
schema.attribute_added, .attribute_modified, .attribute_deprecated
metawebhook_endpoint.failed
Subscribe to "*" for everything, or pass a list of exact event names (no glob patterns in v1).

API surface

VerbPathNotes
POST/webhook-endpointsReturns signing_secret (once)
GET/webhook-endpointsList, paginated
GET/webhook-endpoints/:idSingle
DELETE/webhook-endpoints/:idCascade deletes deliveries
GET/webhook-endpoints/:id/deliveriesAudit log, newest first
POST/webhook-endpoints/:id/testSynchronous fire; returns receiver response

Test endpoint

curl -X POST $SALTY_API/webhook-endpoints/<id>/test \
  -H "Authorization: Bearer $SALTY_API_KEY"
Synchronously POSTs {event: "webhook_endpoint.test", data: {ping: "pong"}} to your URL and returns the receiver’s HTTP status + body. Doesn’t enqueue, doesn’t retry — just a single fire to confirm wiring is right.

Delivery audit

curl $SALTY_API/webhook-endpoints/<id>/deliveries \
  -H "Authorization: Bearer $SALTY_API_KEY"
{
  "data": [
    {
      "id": "…",
      "webhook_endpoint_id": "…",
      "event_type": "person.created",
      "status": "delivered",
      "attempts": 1,
      "response_status_code": 200,
      "response_body": "",
      "last_attempt_at": "2026-05-24T12:00:02Z",
      "next_retry_at": null,
      "created_at": "2026-05-24T12:00:00Z"
    }
  ],
  "next_cursor": null
}
status is one of: pending, in_flight, delivered, failed. next_retry_at is the next scheduled attempt for pending rows; null once terminal.