SavvyIQ

Guides

Webhooks

Webhooks are a /v3 feature

Only /v3 requests emit events. v1 and v2 have no callback path — see Entity Resolution to move over.

Pass webhook_url on any /v3 request and we POST a signed JSON event there the moment it finishes.

Nothing needs registering, and nothing is lost if a delivery fails: every result also stays at GET /v3/runs/{run_id}, so polling is always a fallback.

Ask for a callback

The destination rides the request:

curl -G 'https://api.savvyiq.ai/v3/entity-resolution/async' \
  --data-urlencode 'name=Acme Inc' \
  --data-urlencode 'webhook_url=https://example.com/hooks/savvyiq' \
  --data-urlencode 'custom_id=order_88213' \
  -H 'apikey: YOUR_API_KEY'

Every request can go somewhere different, which is what makes per-tenant and per-environment routing straightforward. Omit webhook_url and you poll, exactly as before.

Your signing secret is created with your account, so the first callback is already signed with no setup step. View, label and rotate secrets at app.savvyiq.ai/settings/webhooks.

webhook_url also works on GET /v3/domain-intelligence and GET /v3/entities/{id}. There, a record we already hold comes back inline and no event is sent — you get a callback only when the response says the record is still building.

Events

Event typeFires when
entity_resolution.completedAn async Entity Resolution request succeeded
entity_resolution.failedIt reached a terminal failure
domain_intelligence.completedA Domain Intelligence record finished building
domain_intelligence.failedThe build failed
entity.completedAn entity’s enrichment finished building
entity.failedThe enrichment failed

Exactly one event per request, per terminal state. There is no “created” or “started” event, and streaming requests produce none — they return their result directly.

What we send

POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: SavvyIQ-Webhooks/1
webhook-id: evt_2ZUKocPbFCPLClZ5XtHlJ
webhook-timestamp: 1755624000
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
savvyiq-event-type: entity_resolution.completed
{
  "id": "evt_2ZUKocPbFCPLClZ5XtHlJ",
  "object": "event",
  "type": "entity_resolution.completed",
  "api_version": "v3",
  "created": 1755624000,
  "data": {
    "object": {
      "run_id": "run_2ZUKocPbFCPLClZ5XtHlJ",
      "custom_id": "order_88213",
      "status": "COMPLETED",
      "data": { }
    }
  }
}

data.object wraps the result one level deep. The poll route returns that same object at the top level, no data wrapper — so a handler written against one only has to unwrap to serve the other.

api_version is the version the request was submitted under; only /v3 produces events, so it is always v3.

The three ids

IdIdentifiesWhere you see it
evt_…one deliveryid, and the webhook-id header. Key your idempotency on it.
run_…one unit of async workdata.object.run_id — the id you got at submit. Poll it at GET /v3/runs/{run_id}.
req_…one HTTP callrequest_id on a response. Quote it to support.

A run outlives the call that started it, so run_id and request_id are never interchangeable.

Match events to your own records

Pass custom_id on the async request and we echo it on the event, on the 202, and on every poll — so you need no run_… → your id table.

curl -G 'https://api.savvyiq.ai/v3/entity-resolution/async' \
  --data-urlencode 'name=Acme Inc' \
  --data-urlencode 'custom_id=order_88213' \
  -H 'apikey: YOUR_API_KEY'

It is a label, nothing more: it does not route the event, does not have to be unique, and cannot be looked up by.

  • Up to 200 characters, printable ASCII. Anything longer or with control characters is rejected with a 400 rather than trimmed, so a mismatch surfaces at integration time.
  • /v3 async requests only. Synchronous and streaming requests return their result directly.
  • It survives the 1 MB truncation below, which is exactly when you need it.

Verify the signature

We follow Standard Webhooks, so any Svix-compatible client verifies for you:

import { Webhook } from 'svix'
import express from 'express'

const app = express()
const wh = new Webhook(process.env.SAVVYIQ_WEBHOOK_SECRET)

// express.raw, not express.json: the signature covers the exact bytes.
app.post('/hooks/savvyiq', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
  event = wh.verify(req.body, {
    'webhook-id': req.get('webhook-id'),
    'webhook-timestamp': req.get('webhook-timestamp'),
    'webhook-signature': req.get('webhook-signature'),
  })
} catch {
  return res.sendStatus(400)
}

// Acknowledge first, process after.
res.sendStatus(204)
handle(event)
})

To implement it yourself: HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, base64-encoded, compared in constant time.

  • The whsec_ prefix is a label. Base64-decode what follows it — those bytes are the key.
  • Sign the raw bytes. Parsing to JSON and re-serializing will not verify.
  • webhook-signature can carry several space-delimited signatures, each v1,<base64>. Accept if any matches and ignore non-v1 entries — this is what makes a rotation invisible to you.
  • Reject timestamps outside a tolerance window (300s is a good default) to stop replays. The timestamp is the delivery time, so a legitimate retry of an old event still passes.

Respond

Return any 2xx within 15 seconds200, 202 and 204 are all fine. Acknowledge first, process afterwards.

  • Any non-2xx, or no response inside the timeout, is a failure.
  • A 3xx is a failure: we do not follow redirects, because the destination has not been through our URL checks. Send us the final URL.

Deliver idempotently. A retry can arrive after your handler succeeded but answered too late, so key your processing on id or run_id.

Retries

Six attempts, roughly five minutes end to end.

AttemptSent after
1immediately
210s
320s
440s
580s
6160s

After the sixth the event is exhausted, and is not retried or replayed. The result is still at GET /v3/runs/{run_id}.

Every attempt is listed under Recent deliveries with its status, duration and run_… id — the place to look when something did not arrive.

Nothing is ever disabled on your behalf, because there is no endpoint to switch off: a bad URL burns its own request’s retry budget and stops there. Other requests are unaffected, even to the same URL.

URL requirements

Checked when you submit and again at delivery, because DNS can change in between. A URL that fails is refused with a 400 at submit, before anything is charged.

  • https:// only.
  • A public hostname. Internal names, .local, and anything resolving to a private, loopback, link-local or reserved address is refused.
  • Port 443, or any port above 1024.
  • No username or password in the URL. Authenticate us with the signature, or use a secret path.

Rotate a signing secret

No coordinated cutover is needed:

  1. Hit Rotate in settings. A new secret is created and the old one keeps working for 24 hours.
  2. Through that window every delivery carries two signatures, one per secret, so a receiver holding either verifies. This is why your verifier must accept any matching signature — every Standard Webhooks library already does.
  3. Update your receiver whenever it suits you. The old secret lapses on its own.

If a secret is compromised and you need it dead now, delete it instead of rotating. You cannot delete your last secret, because an account with none receives unsigned deliveries.

Large payloads

Above 1 MB we send the envelope with data.object.data set to null and add data_truncated: true. Fetch the full result from GET /v3/runs/{run_id}.

Troubleshooting

SymptomCause
Signature never verifiesThe body was parsed and re-serialized. Sign the raw bytes.
Signature verifies for some deliveries onlyA rotation is in flight and your verifier takes the first signature rather than trying each.
Nothing arrives at allThe request carried no webhook_url, was synchronous, or the record was already built and returned inline.
Attempts show “Blocked”The URL failed the checks above, most often a hostname that now resolves to a private address.
Attempts show “No response”Your endpoint did not answer within 15 seconds.