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
  1. Add an endpoint
  2. Events and what they carry
  3. Check the signature
  4. Delivery, retries and the log

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.

  1. In Settings → API and webhooks, under Webhooks, select Add endpoint.
  2. Enter your URL. It must be https on the standard port 443, on a public address.
  3. Pick All events or the events you want, then select Add endpoint.
  4. 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.

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.

Create your account