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 with GET /v1/events/{event_id}, remove evt_;
  • webhook-timestamp: Unix seconds, the time of this attempt;
  • webhook-signature: v1, and the base64 HMAC-SHA256 of webhook-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:

  1. Send the failed and cancelled deliveries again with POST /v1/webhook-endpoints/{id}/recover and a since time, or one delivery with POST /v1/webhook-deliveries/{id}/redeliver. Both refuse a disabled endpoint with 409 webhook_endpoint_disabled. One exception: a redeliver of a webhook.test delivery goes to a disabled endpoint, but not after a 410 Gone. A recover sends 1000 deliveries at most, oldest first, and no test delivery: call it again until it answers 0.
  2. 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.