Help
Use webhooks
Add an endpoint in Settings, every event and its fields, how to check a signature in Node, and how retries, the delivery log and Resend work.
Updated
On this page
Webhooks let Propozit tell your tools when something happens to a proposal. It posts a signed event to your URL, such as when a proposal is sent, opened, signed or declined. Webhooks are on every plan. To read and create proposals yourself, see Use the API.
Add an endpoint
Only owners and admins can add endpoints.
- In Settings → API and webhooks, under Webhooks, select Add endpoint.
- Enter your URL. It must be
httpson the standard port 443, on a public address. - Pick All events or the events you want, then select Add endpoint.
- Copy the signing secret. It is shown once.
Send test posts a ping event at once and shows the result, at most once every 10 seconds. Roll secret makes a new secret; the old one keeps working for 24 hours, and both sign each event until then. Turn off stops sending, and the events of that time are not sent later. You can have up to 10 endpoints.
Tools can also list, add, test and delete endpoints with the API under /webhook-endpoints. The OpenAPI file has the details.
Events and what they carry
Each event is a POST with a JSON body like this:
{
"id": "evt_3f2a9c1e5b7d4e6f8a0b1c2d3e4f5a6b",
"type": "deck.declined",
"created": "2026-10-26T14:05:09.123Z",
"api_version": "2026-10-26",
"source": "client",
"org": { "id": "ORG_ID" },
"data": {
"deck": {
"id": "DECK_ID",
"title": "Halden webshop",
"kind": "proposal",
"version": 2,
"link": "https://propozit.com/d/3a9c…",
"client": { "id": "CLIENT_ID", "company": "Halden Coffee" },
"valid_until": "2026-11-30"
},
"decliner": { "name": "Jo Halden", "email": "jo@halden.example" },
"reason": "Too expensive for now.",
"declined_at": "2026-10-26T14:05:08.990Z"
}
}
source is app, api, client or system. Every event except ping has data.deck. Besides it:
| Event | When | Also in data |
|---|---|---|
deck.published |
A new version is published | nothing |
deck.sent |
Sent by email, or the link shared for the first time | channel (email or link), recipients (emails; empty for a link) |
deck.opened |
The first open | opened_at |
deck.signed |
The client signed | signer (name, email, role), signed_at |
deck.change_requested |
The client asked for a change | request (id, topic, message, email), requested_at |
deck.declined |
The client declined | decliner (name, email), reason, declined_at |
deck.expired |
The valid-until date passed without a signature | expired_on |
payment.client_paid |
The client said they paid | payment (index, amount in cents, currency, reference, status, at) |
payment.marked_paid |
Your team marked a payment as paid | payment, as above |
ping |
Send test | endpoint_id, and no deck |
No event is sent when a proposal moves to Trash, is restored or is deleted forever. A proposal in Trash sends no events, so one that expires there sends no deck.expired.
Some events carry personal data: the recipients' emails, the signer, the requester and their message, and the decliner and the reason they typed, which may be empty. Treat these events as you treat your client emails.
Check the signature
Each event has the headers Propozit-Signature, Propozit-Event-Id and Propozit-Event-Type, and a User-Agent that starts with Propozit-Webhooks/1.0. The signature looks like t=1792937109,v1=5d41…: an HMAC-SHA256, made with your whole signing secret, of the time, a dot and the raw body. During a secret roll there are two v1 values; accept the event when one matches.
In Node, check the raw body before you parse it:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPropozit(header, rawBody, secret, toleranceSec = 300) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
return parts
.filter(([k]) => k === 'v1')
.some(([, v]) => v.length === expected.length && timingSafeEqual(Buffer.from(v), expected));
}
Answer with any 2xx status within 10 seconds, then do the slow work. Propozit doesn't follow redirects.
Delivery, retries and the log
Events usually arrive within a minute, often at once. An event can come more than once, for example after a timeout, so keep the event id and skip one you already handled.
When your URL fails (not a 2xx, a timeout or a redirect), Propozit tries again after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 tries over about 45 hours.
Delivery log on each endpoint shows the last 50 deliveries with the time, the event, the result, the HTTP status, how long it took, the attempt and the error. Resend sends one again with the same event id, once the current try has finished. Events and their log are kept for 30 days.
After 20 failures in a row over at least 3 days, the endpoint is switched off and the owners and admins get an email. Fix your URL, then select Turn on.
Signing secrets are stored encrypted. If the key that encrypts them is ever replaced, endpoints have to be added again.
Related
- Use the API Keys in Settings, the limits and errors, safe retries with Idempotency-Key, and curl examples to read, create and send a proposal.
- Send a proposal and get it signed Send with your own message, unlock with an email code, signing drawn or typed, the signed PDF, and the activity panel.
- Get paid after signing Set up Payments, what your client sees after signing, the QR code, I've paid versus Mark as paid, Request payment, and what Propozit does and does not do.
Proposals your clients actually say yes to
Propozit is free to start, with no card needed. Create your account today and see the plans on the pricing page.