REST API
Webhooks
A webhook endpoint is an HTTPS URL of yours. eSoppari calls it when a signing request changes. The call is signed with the Standard Webhooks scheme, so you can check that it came from us.
Events
| Type | When |
|---|---|
signing_request.sent |
The request was sent. The invitation emails follow. |
signer.signed |
One signer signed. |
signing_request.completed |
The last signer signed. The signed PDF can still get its archive timestamp after this event, so download it when you need it, not at once. |
signer.declined |
A signer declined. |
signer.withdrawn |
Your organisation withdrew a signer who had not signed. The request goes on, or completes when nobody else was left: signing_request.completed follows in that case. |
signing_request.declined |
The request ended because a signer declined. |
signing_request.cancelled |
Your organisation cancelled the request. |
signing_request.expired |
The last day to sign passed. |
creation_session.completed |
A creation session sent its request. signing_request_id names it. |
creation_session.expired |
A creation session expired before a send. |
creation_session.cancelled |
A creation session was cancelled. reason says why: person, api_key_revoked or embed_origin_removed. |
webhook.test |
You sent a test from the portal or the API. |
The body names the resource and its status, and nothing else:
{
"type": "signer.signed",
"timestamp": "2026-10-15T09:00:00Z",
"data": {
"object": "signer",
"id": "8f0c5a8e-2a64-4f6b-9a77-1b0d3f6c2e11",
"status": "signed",
"signing_request_id": "1c4b8f3a-6d2e-4a0b-b5c1-3e9f7d2a6b80"
}
}
Get the details with GET /v1/signing-requests/{id}. A cancelled request has
the status declined, the same as GET shows: only the event type tells a
cancel from a decline.
Events can arrive out of order, and more than once. Use timestamp to
order them, read the current state through the API, and drop a repeat by its
webhook-id header.
Register an endpoint
curl -X POST https://api.esoppari.fi/v1/webhook-endpoints \
-H "Authorization: Bearer $ESOPPARI_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/esoppari", "event_types": ["signing_request.completed"]}'
The answer carries secret, which starts with whsec_. It is shown once.
Store it beside your API key. Leave out event_types to get every event.
The URL must use https on port 443, with a host name, not an IP address. A
host that resolves to a private or loopback address is refused. An
organisation has 10 endpoints at most. Administrators can do the same on
Settings, Webhooks in the admin portal.
Verify each call
Each call has three headers:
webhook-id:evt_and the event id, the same on each retry of one event. To read the event withGET /v1/events/{event_id}, removeevt_;webhook-timestamp: Unix seconds, the time of this attempt;webhook-signature:v1,and the base64 HMAC-SHA256 ofwebhook-id.webhook-timestamp.body. The key is the bytes that the base64 part of your secret decodes to.
Refuse a call whose signature does not match, or whose timestamp is more than 5 minutes away from your clock. Check against the raw body bytes, before you parse the JSON. The Standard Webhooks libraries do all of this:
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.ESOPPARI_WEBHOOK_SECRET);
const event = wh.verify(rawBody, headers); // throws on a bad call
# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["ESOPPARI_WEBHOOK_SECRET"])
event = wh.verify(raw_body, headers) # raises on a bad call
// composer require standard-webhooks/standard-webhooks
// The library reads the header names in lower case only.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$wh = new \StandardWebhooks\Webhook(getenv('ESOPPARI_WEBHOOK_SECRET'));
$event = $wh->verify($rawBody, $headers); // throws on a bad call
With no library, the check is a few lines:
func verify(secret string, h http.Header, body []byte) bool {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
ts, err := strconv.ParseInt(h.Get("webhook-timestamp"), 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(h.Get("webhook-id") + "." + h.Get("webhook-timestamp") + "."))
mac.Write(body)
want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, sig := range strings.Fields(h.Get("webhook-signature")) {
if hmac.Equal([]byte(sig), []byte(want)) {
return true
}
}
return false
}
static bool Verify(string secret, string id, string timestamp, string signatures, byte[] body)
{
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false;
var key = Convert.FromBase64String(secret.Replace("whsec_", ""));
using var mac = new HMACSHA256(key);
var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");
var want = "v1," + Convert.ToBase64String(mac.ComputeHash(prefix.Concat(body).ToArray()));
return signatures.Split(' ').Any(s => CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(s), Encoding.UTF8.GetBytes(want)));
}
Answer with any 2xx within 8 seconds, then do the work. A slow answer counts as a failure.
Retries, failure and resend
| What happens | Rule |
|---|---|
| A call fails: no 2xx, a timeout, or a redirect | eSoppari tries again after 5 seconds, 5 minutes, 30 minutes, 2, 5, 10 and 10 hours: 8 attempts over about 28 hours |
Your endpoint answers 410 Gone |
eSoppari disables the endpoint at once, also for a test event. A new test or a redeliver of a test then gives 409 webhook_endpoint_disabled until you enable the endpoint again, also after you disable it |
| Every call fails for 72 hours, with no gap over 24 hours between failures | eSoppari disables the endpoint and emails your administrators. A failed test event does not count |
| Your organisation is suspended | The calls wait, for 30 days at most, and are sent when it is active again |
Enable the endpoint again with POST /v1/webhook-endpoints/{id}/enable. The
deliveries that were still waiting for a retry when it was disabled are
cancelled, and no event is recorded for an endpoint while it is disabled.
So, after you enable it:
- Send the failed and cancelled deliveries again with
POST /v1/webhook-endpoints/{id}/recoverand asincetime, or one delivery withPOST /v1/webhook-deliveries/{id}/redeliver. Both refuse a disabled endpoint with 409webhook_endpoint_disabled. One exception: a redeliver of awebhook.testdelivery goes to a disabled endpoint, but not after a410 Gone. A recover sends 1000 deliveries at most, oldest first, and no test delivery: call it again until it answers 0. - Read the current state of your requests through the API, for the time the endpoint was disabled.
The delivery log keeps 30 days.
Rotate the secret
POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret, once.
For 24 hours each call carries two signatures in webhook-signature, one
with the new secret and one with the old, so you can deploy the new secret
without a gap. The libraries above accept either. A second rotation within the 24 hours
ends the oldest secret at once.
Test
POST /v1/webhook-endpoints/{id}/test sends a webhook.test event through the
normal path, also to an endpoint that is disabled. The answer is the delivery; read
its result with GET /v1/webhook-deliveries/{id}. A test makes one attempt, with
no retry. While a test of the endpoint waits for its attempt, a new call returns
that test, and a redeliver of a test gives 409 webhook_delivery_pending.