Receive webhooks
A webhook is AssinaJá knocking on your system’s door when something happens to a
document, instead of your system having to keep asking. It is a POST request
the platform makes to an address of yours.
This is material for whoever integrates AssinaJá with another program. If you only want to see what happened to a document, use the document history.
The address the platform accepts
Section titled “The address the platform accepts”The address is not free-form. The platform validates it before storing it, so it cannot be used to knock on machines that are not yours:
- HTTPS, always.
- Port 443 or 8443, and no other.
- A public hostname: not
localhost, not*.localhost, not*.internal, notmetadata.google.internal. - A public address. If the name resolves to a private or reserved address —
10.x,172.16–31.x,192.168.x,127.x,169.254.x,100.64.x, multicast, or the IPv6 equivalents — it is refused. Every address the name resolves to is checked, not just the first.
The refusal is shown in your own language:
| Refusal | What to do |
|---|---|
| “Enter the webhook address.” | the field is empty |
| “The address is not a valid URL.” | the scheme is missing, or there is a typo |
| “The address must use HTTPS.” | swap http:// for https:// |
| “The port must be 443 or 8443.” | drop the port, or use one of these |
| “The host is reserved (localhost, .internal, cloud metadata) and cannot receive webhooks.” | use a name reachable from the internet |
| “The IP address is not public.” | you typed a private IP directly |
| “The host name does not resolve to any address.” | check your DNS |
| “The host name could not be resolved.” | DNS failed; try again |
| “The host resolves to an address that is not public.” | the name points inside your network |
When choosing events, “One of the selected events is not recognised.” means the list sent carries a name the platform does not know — check it against the table above. And “No webhook address is configured — enter one or save the subscription first.” appears when you try to subscribe to events before there is an address.
The events
Section titled “The events”| Event | When it is sent |
|---|---|
document.completed |
Everyone has signed and the final document is ready |
document.rejected |
A signer declined |
document.canceled |
The owner cancelled the document |
document.expired |
The deadline passed without completion |
document.pdf_application_failed |
The signature could not be applied to the PDF |
recipient.signed |
One signatory signed — once per signatory |
document.sent |
The document was published and the invitations went out |
The last two are new and are not final: the document keeps moving after them. Do not treat any single event as “this is finished” — read the document status carried in the body.
The document.pdf_application_failed is the one not to ignore: it means somebody signed but the signature
never made it into the file. In AssinaJá that surfaces as Technical issue on
the document detail, with Try again — see
Track and manage documents.
The Idempotency-Key header exists so your side can discard repeats, but it
no longer has a single form. It depends on where the event is going and which
event it is:
| Destination | Event | Key |
|---|---|---|
The document’s address (webhookUrl) |
any except recipient.signed |
{documentPublicId}:{event} |
| The document’s address | recipient.signed |
{documentPublicId}:{event}:{recipientId} |
| The organization’s address | any except recipient.signed |
{documentPublicId}:{event}:org |
| The organization’s address | recipient.signed |
{documentPublicId}:{event}:{recipientId}:org |
Store and compare the whole key, as an opaque string. Do not split it into
parts. If your database has a uniqueness constraint on (document, event), it
will start rejecting legitimate deliveries: recipient.signed arrives once per
signatory, and a document with its own address inside an organization that also
has one configured produces two deliveries of the same event, with different
keys.
The webhook secret
Section titled “The webhook secret”The secret is what lets you be sure the request really came from AssinaJá. It lives under Settings → Developers, in the Webhooks tab, Secret sub-tab. Only the organization’s Owner can see it.
Webhook secret:
The secret is used to verify the signature of events sent to your webhook.
Use Generate secret the first time, and Copy to store it. Do not leave that page without having stored it:
Save this value now — it will not be shown again.
Changing the secret
Section titled “Changing the secret”Rotate issues a new secret without interrupting deliveries:
Are you sure you want to rotate the secret? The previous secret will be valid for 24 hours.
During those 24 hours — “Previous secret stays valid for 24 h.” — requests go out signed with both secrets, comma-separated in the same header. That is why verification has to accept a list rather than a single value. After that window, only the new one is used.
Verifying the signature
Section titled “Verifying the signature”Every request carries these headers:
| Header | What it is |
|---|---|
X-AssinaJa-Signature |
The signature, v1=<hex>, or several comma-separated during a rotation |
X-AssinaJa-Event |
The event name |
X-AssinaJa-Delivery |
A new identifier on every attempt — it is not usable for deduplication |
X-AssinaJa-Timestamp |
The time, in Unix seconds |
Idempotency-Key |
See the table above — four possible forms |
The signature is an HMAC-SHA256 over the timestamp and the request body joined by
a dot — {timestamp}.{body} — in lowercase hex and prefixed with v1=.
The key is not the secret as you copied it. The secret is base64url-encoded; the HMAC key is the bytes you get by decoding it. Passing the string straight to your HMAC function produces a signature that never matches — it is the most common cause of verification failing.
const crypto = require('crypto');
function verifyWebhookSignature(secret, timestamp, body, signatureHeader) { const message = `${timestamp}.${body}`; // the secret is base64url — the key is the decoded bytes, not the string const secretBytes = Buffer.from(secret, 'base64url'); const expected = 'v1=' + crypto .createHmac('sha256', secretBytes) .update(message) .digest('hex');
// during a rotation both signatures arrive const signatures = signatureHeader.split(',').map(s => s.trim()); return signatures.some(sig => sig === expected);}Use the body exactly as it arrived, raw. Parse it as JSON and re-serialise it and the bytes change, so the signature stops matching.
What the platform does with your answer
Section titled “What the platform does with your answer”| What you answer | What happens |
|---|---|
2xx |
Delivered. No retry. The response body must fit in 4 KB. |
3xx |
It tries again — redirects are not followed. |
4xx |
It tries again, exactly like a 5xx. |
5xx |
It tries again. |
| No answer within 10 seconds | It tries again. |
Only a 2xx counts as delivered. There is no status code that makes the
platform give up on purpose: if you want a delivery to stop, answer 2xx and
discard the content on your side.
Retries are spaced 1 min, 2 min, 5 min, 15 min, 30 min, 60 min and 60 min. There are eight in total, and the eighth is a real delivery attempt — only when that one fails is the delivery marked as failed.
Checking that it arrived
Section titled “Checking that it arrived”Each document’s detail shows the last delivery for that document, whatever the event, in the Integration card — with Webhook (the event name), Status, Attempts, Delivered on and, if it failed, the Error. It is the quickest place to tell “AssinaJá did not send it” from “my server did not accept it”. There is no resend button on this card.
For the organization’s full history, and to resend failed deliveries, go to Settings → Developers → Webhooks, to the Deliveries sub-tab. Only the organization’s Owner can reach that screen.
When a resend does not take, the screen says why:
That delivery no longer exists.
That delivery was already received — only failed ones can be resent.
This delivery cannot be resent right now.