Skip to content

Webhooks

Webhooks are HTTP requests esigna sends to your app when something happens in a signing process. They are not instant and may arrive out of order, and the same event can arrive more than once.

Portal API

Webhooks are part of the Portal API.

To receive events:

  1. Set up an endpoint that checks signatures and answers pings (below).
  2. Create a subscription for the events you want.
  3. Activate it. Until then it receives nothing.

Your endpoint

esigna only calls endpoints that:

  • use https:// and a public address: no localhost, private network or link-local addresses, and no user name or password in the URL. The address is checked again on every connection, and redirects are not followed;
  • answer within 15 seconds;
  • answer a correctly signed request with a 2xx status (the body is ignored);
  • answer a request whose signature doesn't verify with 401 Unauthorized.

Headers

Each request carries:

Header Content
webhook-id The event's ID. Retries of the same event keep the same ID.
webhook-timestamp When the request was sent, in Unix seconds.
webhook-signature One or more signatures, separated by spaces.
webhook-subscription-id The ID of the subscription the request is for.

Checking the signature

Check the signature on every request, before anything else. If no signature verifies, answer 401 Unauthorized.

The signed content is the webhook-id, the webhook-timestamp and the raw request body, joined with periods:

{webhook-id}.{webhook-timestamp}.{body}

Use the body exactly as received. webhook-signature lists signatures as scheme,base64-signature, separated by spaces:

v1,{base64-hmac-sha256} v1a,{base64-ed25519}
Scheme Algorithm Key
v1 HMAC-SHA256 Your subscription's secret: the 64 hexadecimal characters returned once when you created the subscription, decoded to its 32 bytes.
v1a ed25519 esigna's public key (see Public keys).

Accept the request if any one signature verifies. The list may grow, for example during a key change, so ignore schemes you don't know.

Also refuse requests whose webhook-timestamp is more than about five minutes from your clock, so a captured request can't be replayed later.

import base64
import hashlib
import hmac
import time

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.serialization import load_ssh_public_key

WEBHOOK_SECRET = "3F1C...64 hexadecimal characters"  # from the create response
with open("webhooks-production.pub", "rb") as f:
    ESIGNA_PUBLIC_KEY = load_ssh_public_key(f.read())

def is_from_esigna(headers, body: bytes) -> bool:
    webhook_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False

    signed = f"{webhook_id}.{timestamp}.".encode() + body
    for entry in headers["webhook-signature"].split(" "):
        scheme, _, value = entry.partition(",")
        if scheme == "v1":
            key = bytes.fromhex(WEBHOOK_SECRET)
            expected = hmac.new(key, signed, hashlib.sha256).digest()
            if hmac.compare_digest(expected, base64.b64decode(value)):
                return True
        elif scheme == "v1a":
            try:
                ESIGNA_PUBLIC_KEY.verify(base64.b64decode(value), signed)
                return True
            except InvalidSignature:
                pass
    return False

Public keys

esigna's ed25519 public keys, in OpenSSH format:

Environment Key
Production https://docs.esigna.is/keys/webhooks-production.pub
Sandbox https://docs.esigna.is/keys/webhooks-sandbox.pub

Duplicates

esigna may send the same event more than once, for example when your endpoint handled a request but its answer didn't reach esigna in time. Store the webhook-id of each event you have handled and skip repeats.

Check the signature before looking at webhook-id. Activation and the security checks send requests that must be refused, and a handler that skips "repeats" before checking the signature would answer them with 2xx.

Pings and security checks

esigna sends a webhook.ping to every active subscription about once an hour, whether or not it subscribes to webhook.ping. Answer it with 2xx.

At random times, every one to five hours, esigna also sends a security check: a ping signed so that it can't verify, which your endpoint must answer with 401.

  • If your endpoint doesn't answer, or answers with an error, esigna tries again every 5 minutes. The account's admins get an email about it, at most one a day.
  • After three days in which every ping failed, the subscription is switched off and the admins get an email.
  • If your endpoint answers a security check with 2xx, it accepted a request it could not have verified: the subscription is switched off at once and the admins get an email. An outage during a security check doesn't count; the check just waits until your endpoint answers.

A switched-off subscription receives nothing until you activate it again.


Managing subscriptions

Scopes required

portal/read to list and get subscriptions; portal/write for everything else.

Creating a subscription

POST /api/v1/portal/webhooks

Field Type Required Description
url string Yes Your endpoint, at most 512 characters. See Your endpoint.
eventTypes array Yes The events to receive.
import requests

API = "https://api.esigna.is/api/v1"

def create_subscription(token: str):
    r = requests.post(
        f"{API}/portal/webhooks",
        json={
            "url": "https://example.is/esigna-events",
            "eventTypes": ["participant.signed", "participant.declined", "process.completed"],
        },
        headers={"Authorization": f"Bearer {token}"},
    )
    r.raise_for_status()
    return r.json()
1
2
3
4
5
6
7
8
curl https://api.esigna.is/api/v1/portal/webhooks \
  --request POST \
  --header "Authorization: Bearer $TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{
    "url": "https://example.is/esigna-events",
    "eventTypes": ["participant.signed", "participant.declined", "process.completed"]
  }'

Example response: 201 Created

The Location header holds the subscription's address, /api/v1/portal/webhooks/{id}.

1
2
3
4
5
6
7
8
{
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "url": "https://example.is/esigna-events",
    "secret": "3F1C2D4E5B6A7980...",
    "isActive": false,
    "eventTypes": ["participant.signed", "participant.declined", "process.completed"],
    "createdAt": "2026-10-09T12:00:00Z"
}

Warning

secret is returned only here, once. Store it securely: it can't be read again.

A 400 names the problem: a URL that isn't public https://, or an event type that isn't available.

Activating a subscription

POST /api/v1/portal/webhooks/{id}/activate

New subscriptions are inactive. Activation sends your endpoint two pings, each with its own webhook-id:

  1. one correctly signed, which must get a 2xx;
  2. one that can't verify, which must get 401 Unauthorized.

If both pass, the subscription is active and the response is 200 with the subscription. If either fails, or your endpoint can't be reached, the response is 422 Unprocessable Entity and says which check failed:

{
    "error": "Endpoint did not respond with 401 Unauthorized to a ping with an invalid signature."
}

The two pings can take up to 30 seconds. If the subscription's URL is changed while they run, the response is 409 Conflict and the subscription stays inactive: activate it again, so the new URL gets checked.

curl https://api.esigna.is/api/v1/portal/webhooks/$ID/activate \
  --request POST \
  --header "Authorization: Bearer $TOKEN"

Sending a test event

POST /api/v1/portal/webhooks/{id}/test

Sends one correctly signed webhook.ping. Returns 204 No Content if your endpoint answered 2xx, and 502 Bad Gateway if it answered anything else, timed out or couldn't be reached.

Listing and getting subscriptions

GET /api/v1/portal/webhooks lists your subscriptions; GET /api/v1/portal/webhooks/{id} returns one, or 404. Neither returns the secret.

1
2
3
4
5
6
7
8
{
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "url": "https://example.is/esigna-events",
    "isActive": true,
    "eventTypes": ["participant.signed", "participant.declined", "process.completed"],
    "createdAt": "2026-10-09T12:00:00Z",
    "updatedAt": "2026-10-09T12:05:00Z"
}

Updating a subscription

PUT /api/v1/portal/webhooks/{id}

Send only the fields you want to change.

Field Type Description
url string A new endpoint. Changing the URL makes the subscription inactive until you activate it again.
eventTypes array A new list of events.
isActive bool false switches the subscription off. true is refused with 422: use activate.

Returns 200 with the subscription, 400 for a URL or event type that isn't allowed, or 404.

Deleting a subscription

DELETE /api/v1/portal/webhooks/{id} deletes the subscription for good. Returns 204 No Content, or 404.


Delivery and retries

Events come from the signing processes your account created through the API; documents sent from the esigna web app send none. esigna sends each event to the subscriptions that include its type and are active when it is first sent, normally within seconds of it happening: a subscription created or activated later doesn't get earlier events.

If your endpoint doesn't answer 2xx within 15 seconds, the event is sent again, to that subscription only:

Attempt When
1 As soon as the event happens
2 10 seconds after attempt 1 failed
3 1 minute after attempt 2 failed
4 5 minutes after attempt 3 failed
5 30 minutes after attempt 4 failed
6 2 hours after attempt 5 failed
7 6 hours after attempt 6 failed
8 24 hours after attempt 7 failed; after that the event is dropped

So an event is retried for about a day and a half. If your endpoint was down for longer, read the signing process's status to catch up.


Events

Every event body has the same shape: a type, a timestamp and a data object.

Event When
participant.signed A participant signed the document.
participant.declined A participant declined to sign.
process.completed Every signer has signed.
webhook.ping Liveness check. Sent to every active subscription; you don't subscribe to it.

kennitala and phone are empty strings when esigna doesn't know them.

participant.signed

Field Type Description
data.signingProcessId string (UUID) The signing process.
data.participant.name string The signer's name, as their electronic ID gives it.
data.participant.email string The email address the invitation went to.
data.participant.kennitala string The signer's kennitala, as their electronic ID gives it.
data.participant.phone string The phone number given when the process was created.
data.participant.signature string (Base64) The signer's signature value.
data.participant.signedAt string (ISO 8601, with offset) When they signed.
{
    "type": "participant.signed",
    "timestamp": "2026-10-09T13:58:49.407+00:00",
    "data": {
        "signingProcessId": "c93e6bf4-a256-4b97-9abb-c7650cdc87f5",
        "participant": {
            "name": "Jón Jónsson",
            "email": "jon@example.is",
            "kennitala": "0101302989",
            "phone": "",
            "signature": "HiI76ZWOyHP98jqXs6QOvrDK...",
            "signedAt": "2026-10-09T13:58:49.407+00:00"
        }
    }
}

participant.declined

Field Type Description
data.signingProcessId string (UUID) The signing process.
data.participant.name string The participant's name.
data.participant.email string The email address the invitation went to.
data.participant.kennitala string The participant's kennitala, if known.
data.participant.phone string The phone number given when the process was created.
data.participant.declineReason string The reason the participant gave.
data.participant.declinedAt string (ISO 8601, with offset) When they declined.
{
    "type": "participant.declined",
    "timestamp": "2026-10-09T13:58:49.407+00:00",
    "data": {
        "signingProcessId": "c93e6bf4-a256-4b97-9abb-c7650cdc87f5",
        "participant": {
            "name": "Jón Jónsson",
            "email": "jon@example.is",
            "kennitala": "0101302989",
            "phone": "",
            "declineReason": "Rangt nafn á samningnum",
            "declinedAt": "2026-10-09T13:58:49.407+00:00"
        }
    }
}

process.completed

Sent when every signer has signed. The event doesn't carry the document: download the signed document with the signingProcessId.

Field Type Description
data.signingProcessId string (UUID) The signing process.
data.participants array The signers (viewers are not listed), each with name, email, kennitala, phone, signature and signedAt as in participant.signed.
{
    "type": "process.completed",
    "timestamp": "2026-10-09T14:58:50.407+00:00",
    "data": {
        "signingProcessId": "c93e6bf4-a256-4b97-9abb-c7650cdc87f5",
        "participants": [
            {
                "name": "Jón Jónsson",
                "email": "jon@example.is",
                "kennitala": "0101302989",
                "phone": "",
                "signature": "HiI76ZWOyHP9...",
                "signedAt": "2026-10-09T13:58:49.407+00:00"
            }
        ]
    }
}

webhook.ping

1
2
3
4
5
{
    "type": "webhook.ping",
    "timestamp": "2026-10-09T13:58:49.407+00:00",
    "data": {}
}