SignSecure Docs

Receive events with webhooks

Webhooks tell your server when something happens to an envelope: when it is opened, when each person signs, and when the signed PDF is ready. No polling, and no waiting.

How it works

You give SignSecure a URL. Whenever something happens to one of your envelopes, SignSecure sends an HTTP POST to that URL with an event as JSON. Your server checks the signature, does what it needs to, and answers with a 2xx status.

Every event carries the whole envelope as it was at that moment, including your metadata, so you rarely need to call the API back to find out what changed.

An envelope.completed event
{
  "id": "evt_4e6a8c0b2d3f5a7c9e1b2d",
  "object": "event",
  "type": "envelope.completed",
  "created": 1791583201,
  "data": {
    "object": {
      "id": "env_3f9a1c7e2b8d4a6f9c0e1b",
      "object": "envelope",
      "status": "completed",
      "subject": "Order form, Northwind Traders",
      "metadata": {
        "crm_record": "006Qy00000AbCdE"
      },
      "completed_at": 1791583200,
      "documents": [
        {
          "id": "doc_1b4d7f0a3c6e9b2d5f8a1c",
          "signed_sha256": "4b8e2f6a0d3c7b1e5f9a2d6c0b4e8f3a7d1c5b9e2f6a0c4d8b3e7f1a5c9d2b6e",
          "…": "…"
        }
      ],
      "…": "…"
    }
  }
}

1. Create an endpoint

An endpoint is a URL on your server that accepts POST requests. It must be https, on a public hostname, on port 443 or 8443. SignSecure refuses IP addresses and private hostnames, and never follows redirects.

Add it under Developers in the app, or with the API using a key made by an admin. Choose the events you want, or all of them. Listening only to what you use keeps your server quieter.

The response includes the endpoint's signing secret, starting whsec_. It is shown once. Store it with your other secrets.

POST/v1/webhook_endpoints
curl https://www.signsecure.org/v1/v1/webhook_endpoints \
  -H "Authorization: Bearer $SIGNSECURE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.yourcompany.com/signsecure",
    "enabled_events": [
      "envelope.completed",
      "envelope.declined"
    ]
  }'
Response
{
  "id": "we_8a0c2e4b6d1f3a5c7e9b0d",
  "object": "webhook_endpoint",
  "url": "https://hooks.yourcompany.com/signsecure",
  "enabled_events": [
    "envelope.completed",
    "envelope.declined"
  ],
  "status": "enabled",
  "secret": "whsec_Xq3vK8mN2pR5tW9yB4cF7hJ0lS6dG1aE3uZ8iO2nV5k"
}

2. Write the handler

Your handler needs to do three things:

  1. Accept a POST with a JSON body.
  2. Check the signature, using the raw body exactly as it arrived.
  3. Answer with a 2xx status within 10 seconds. Anything else, or no answer, counts as a failure and is retried.

Check the signature against the raw request body. Many frameworks parse JSON before your code sees it, and re-serialising the parsed object rarely gives back the same bytes, so the signature will not match.

If your framework protects POST routes against cross-site request forgery, exempt this route: the signature is its protection.

Webhook handler

3. Check the signature

Anyone who finds your endpoint's URL can send it a request, so check that each one came from SignSecure before you act on it. Every delivery has a SignSecure-Signature header:

SignSecure-Signature: t=1791583201,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is when the delivery was signed, in Unix seconds. v1 is an HMAC-SHA256 signature, in lower-case hex, made with your endpoint's signing secret. To check it:

  1. Split the header. Split on , and then on = to get t and v1. Ignore any other schemes.
  2. Build the signed payload. Join t, a full stop, and the raw request body: t + "." + body.
  3. Compute the expected signature. HMAC-SHA256 of the signed payload, keyed with the whole secret including whsec_, as hex.
  4. Compare. Compare your result with v1 using a constant-time comparison, and check that t is within five minutes of now.

The handlers in step 2 do exactly this in a few lines. A timing-safe comparison matters: an ordinary string comparison can leak how much of a forged signature was right.

Signed payload
1791583201.{"id":"evt_4e6a8c0b2d3f5a7c9e1b2d","object":"event","type":"envelope.completed",…}

Replay protection

A replay attack is someone capturing a genuine delivery and sending it to you again later. The timestamp is inside the signed payload, so it cannot be changed without breaking the signature; refusing anything older than five minutes stops an old delivery from being reused.

Each retry is signed afresh with a new timestamp, so a retried delivery passes this check. Keep your server's clock right with NTP, or genuine deliveries will start failing it.

Delivery and retries

SignSecure tries each delivery as soon as the event happens. If your server does not answer with a 2xx within 10 seconds, it tries again with growing gaps: within about five minutes, then after 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. That is eight attempts over about two days. Redirects count as failures.

If an endpoint fails 50 attempts in a row, SignSecure switches it off and the Developers screen says so. Turn it back on there, or with the API, once it is fixed. Nothing is lost while it is off: events stay readable through List all events for 30 days.

Events are not guaranteed to arrive in order. Use each event's created time, or the envelope's status, rather than the order of arrival.

Best practices

Handle duplicates

A delivery can occasionally arrive twice, for example when your server did the work but its response was lost. Record the event id (also sent as the SignSecure-Event-Id header) and skip any you have already handled.

Answer quickly, work later

Return 2xx as soon as the signature checks out, and do slow work such as downloading the signed PDF or updating your CRM in a background job.

Subscribe to what you use

Choose specific event types rather than all of them, so your server only hears about what it acts on.

Catch up after an outage

If your endpoint was down, page through List all events from where you stopped, rather than waiting for retries.

Keep the secret secret

If you think a signing secret has leaked, create a new endpoint, move your handler to its secret, then delete the old one.

Use metadata to find your record

Put your own record id in the envelope's metadata when you create it. It is in every event, so the handler never has to search.

Events you can receive

TypeOccurs when
envelope.sentAn envelope was sent for signature.
recipient.viewedA signer opened the document for the first time.
recipient.signedA signer finished signing. Others may still need to.
envelope.completedEveryone has signed. The signed PDF is ready to download.
envelope.declinedA signer declined. Nobody else will be asked to sign.
envelope.voidedThe envelope was voided.
envelope.expiredThe signing links ran out before everyone signed.

New types may be added. Ignore any you do not recognise, and answer 2xx anyway.

Troubleshooting

What you seeLikely causeWhat to do
Every signature check failsThe body was parsed or re-encoded before checking.Check the raw bytes, before any JSON middleware.
Some checks fail with an old tYour server's clock has drifted.Sync the clock with NTP.
Last status is 301 or 302Your URL redirects, perhaps from http to https or to a trailing slash.Use the final URL. Redirects are not followed.
Last status is emptyYour server took longer than 10 seconds, or could not be reached.Respond first, then do the work.
The endpoint shows as disabledIt failed 50 attempts in a row.Fix it, then turn it back on in Developers.