Webhooks
Finovel tells your server when something happens, so you do not have to keep asking.
Setting up an endpoint
Add an HTTPS address in the console, on the Webhooks page. You can add up to 10 for each environment. Sandbox events go only to sandbox endpoints, and production events only to production endpoints.
Finovel shows the signing secret once, so store it with your server's other secrets. Private and internal addresses are refused.
Events
Choose the events an endpoint receives, or leave them all unticked to receive every event, including ones added later.
| Event | When |
|---|---|
job.completed | An async job finished: succeeded, failed or error. Fetch the result from GET /v1/jobs/{id}. |
esign.envelope.completed | Everyone has signed an envelope, and the signed PDF is ready. |
esign.envelope.declined | A signer declined to sign an envelope. |
esign.envelope.expired | An envelope expired before everyone signed. |
webhook.test | A test event you sent from the console. |
What you receive
A POST with a JSON body.
{
"id": "evt_8mQ2xV...",
"type": "job.completed",
"created_at": "2026-11-01T09:30:00.000Z",
"environment": "production",
"data": {
"job_id": "job_...",
"request_id": "req_...",
"api": "bank-statement-analysis",
"status": "succeeded",
"finished_at": "2026-11-01T09:29:58.000Z"
}
}An event says what happened, and does not carry the result. Fetch that with your API key, so results only ever reach someone who holds one.
Checking a delivery
Every delivery is signed the Standard Webhooks way. Check the signature before you trust a delivery.
| Header | Meaning |
|---|---|
webhook-id | The event id. It stays the same when a delivery is retried or resent. |
webhook-timestamp | When this attempt was sent, in seconds since 1970. |
webhook-signature | v1, then a comma, then the base64 HMAC-SHA256 signature. |
// Node.js: check a delivery before trusting it. Use the raw body, before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older than 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = 'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
return headers['webhook-signature'].split(' ').some((sig) =>
sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}Answering and retries
Answer with any 2xx status within 10 seconds. Do slow work after you answer. Redirects are not followed.
Anything else is tried again, up to 8 attempts over about 42 hours: after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours.
An endpoint is switched off after 20 deliveries in a row fail for good. Switch it back on from the console, where you can also see each delivery and send it again.
Handling repeats
The same event can arrive more than once, and events can arrive out of order. Keep the webhook-id of events you have handled and ignore repeats.
Replacing a secret
Replace a secret from the console. The old one stops working at once, so update your server straight away.