# Guide to OneSpan Webhooks: Features and Best Practices

OneSpan webhooks tell your systems how an e-signature transaction is progressing. When a OneSpan Sign package is created, sent, viewed, signed, declined, completed or expires, OneSpan can notify your application straight away, so you can update a deal, file the signed documents, chase a signer or start the next step of a workflow without polling the API.

This guide covers how OneSpan Sign callback notifications are registered and authenticated, the events you can subscribe to and what their payloads contain, the inconsistencies in OneSpan's own examples, and the practices that make a OneSpan receiver dependable.

## What are OneSpan webhooks?

OneSpan calls its webhooks **callback event notifications**. They belong to OneSpan Sign, the e-signature product, and they report events on packages, which the newer interface calls transactions. Each account registers one callback URL, an optional **callback key**, and the list of events it wants. When one of those events happens, OneSpan Sign sends an HTTP `POST` with a JSON body describing it.

OneSpan does not sign callbacks. Instead, the callback key you register is sent back in the `Authorization` header as `Basic {callbackKey}`. Note what that is: the word `Basic`, a space, and your raw key. It is not a base64-encoded `username:password` pair, so HTTP Basic parsers will not read it correctly. Compare the whole header value instead.

## OneSpan webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Admin** > **Event Notification**, the Java or .NET SDK, or `POST /api/callback` |
| Scope | One callback URL and key per account, with the events you select |
| Method and format | `POST`, JSON body |
| Authentication | `Authorization: Basic {callbackKey}`, the raw key. The key is optional when registering |
| Signature and timestamp | None |
| Event field | `name` in the body |
| Common fields | `@class`, `name`, `sessionUser`, `packageId`, `message`, `documentId`, `createdDate` |
| Field limits | `sessionUser` and `packageId` up to 255 characters |
| Timeout, retries, ordering | Not documented |
| Source IPs | Not published. FedRAMP customers must have callback domains allowlisted by OneSpan Support |

## Common events

OneSpan documents 26 events.

| Area | Events | Fires when |
| --- | --- | --- |
| Package lifecycle | `PACKAGE_CREATE`, `PACKAGE_ACTIVATE`, `PACKAGE_DEACTIVATE`, `PACKAGE_READY_FOR_COMPLETE`, `PACKAGE_COMPLETE`, `PACKAGE_EXPIRE` | A package is created, sent, moved back to draft, waits for the sender to complete it, completes or expires |
| Package housekeeping | `PACKAGE_ARCHIVE`, `PACKAGE_TRASH`, `PACKAGE_RESTORE`, `PACKAGE_DELETE`, `TEMPLATE_CREATE` | A package is archived, trashed, restored or deleted, or a template is created |
| Recipients | `PACKAGE_DECLINE`, `PACKAGE_DISPUTE`, `PACKAGE_ATTACHMENT`, `ROLE_REASSIGN`, `COBROWSE_REQUEST` | A recipient declines with a reason, disputes a specification, uploads an attachment, delegates to someone else, or asks the sender for a co-browsing session |
| Documents and signers | `DOCUMENT_VIEWED`, `DOCUMENT_SIGNED`, `SIGNER_COMPLETE`, `SIGNER_LOCKED`, `KBA_FAILURE` | A document is viewed or signed, a signer finishes all documents, a signer is locked out after repeated SMS or Q&A failures, or KBA authentication fails |
| Delivery and recordings | `EMAIL_BOUNCE`, `RECORDINGS_READY` | An email bounces, or a Virtual Room recording is ready to download |
| eDeposit | `EDEPOSIT_SUCCESS`, `EDEPOSIT_FAILURE`, `EDEPOSIT_FAILURE_ATTEMPT` | Documents are deposited to eOriginal, fail unrecoverably, or fail with more automated attempts to come |

Here is OneSpan's example of a completed package:

```json
{
  "@class": "com.silanis.esl.packages.event.ESLProcessEvent",
  "name": "PACKAGE_COMPLETE",
  "sessionUser": "e00696ec-d6f5-4feb-89c5-a5ce002a6c66",
  "packageId": "5n4obeO8jYoPp126Cm-Y3fxdfbo=",
  "message": null,
  "documentId": null,
  "createdDate": "2018-06-30T20:15:01.038Z"
}

```

`packageId` is the transaction. `sessionUser` is the sender's ID for sender-triggered events and the signer's ID for signer-triggered ones. On `PACKAGE_COMPLETE` with several signers, it is the last signer. It is `null` on system events such as eDeposit. `message` carries the bounce type on `EMAIL_BOUNCE`, the reason on `PACKAGE_DECLINE` and the attachment name on `PACKAGE_ATTACHMENT`. `documentId` is set on document events.

A few behaviours are worth knowing before you write a handler. `DOCUMENT_SIGNED` also fires when a signer accepts the consent or disclosure agreement, with `documentId` set to `default-consent`. `PACKAGE_CREATE` fires again if an API call puts a package that is already a draft into draft status. And after a `ROLE_REASSIGN`, later events identify the new signer by signer ID, so record it from `sessionUser` at that point.

> **Inspect OneSpan webhooks as they arrive.** Register a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as your callback URL in the sandbox to see each event's exact body and `Authorization` header, with no account or setup required.

## Setting up OneSpan webhooks

Generate a long random callback key first, for example with `openssl rand -hex 32`.

In the OneSpan Sign interface, go to **Admin** > **Event Notification**, enter your HTTPS callback URL and key, and turn on the events you need.

To register through the REST API, call `POST /api/callback` with your API key:

```bash
curl -X POST "https://sandbox.esignlive.com/api/callback" \
  -H "Authorization: Basic $ONESPAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/onespan",
    "key": "your-long-random-callback-key",
    "events": ["PACKAGE_CREATE", "PACKAGE_DECLINE", "PACKAGE_COMPLETE"]
  }'

```

Use your production API URL instead of the sandbox for live accounts. With the Java SDK the same registration is `eslClient.getEventNotificationService().register(newEventNotificationConfig(URL).withKey(KEY).forEvent(NotificationEvent.PACKAGE_COMPLETE))`. Afterwards, **Admin** > **Event Notification** shows the selected events toggled on.

Both `url` and `key` are optional in the API's request payload, so it is possible to register a callback with no key. Do not: without one, OneSpan sends nothing you can check.

FedRAMP customers have an extra step. For new or changed callback destinations, OneSpan Support must allowlist your callback domains before you configure them.

To route through Hookdeck, create a Hookdeck source with the **OneSpan** type and register its URL as the callback URL. The source has one field, **Authorization Header**: enter `Basic ` followed by your callback key, exactly as OneSpan sends it. It accepts `POST`.

## Securing OneSpan webhooks

Compare the whole `Authorization` header with `Basic ` plus your callback key, in constant time. Do not base64-decode the value.

```javascript
const crypto = require('crypto');
const express = require('express');

const CALLBACK_KEY = process.env.ONESPAN_CALLBACK_KEY;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/onespan', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('authorization');
  // OneSpan sends the raw key after "Basic ", not base64 username:password.
  if (!CALLBACK_KEY || typeof received !== 'string' || !safeEqual(received, `Basic ${CALLBACK_KEY}`)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  switch (event.name) {
    case 'PACKAGE_COMPLETE':
      // Download the signed documents for event.packageId.
      break;
    case 'PACKAGE_DECLINE':
      // event.message holds the decline reason.
      break;
    default:
      break;
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

CALLBACK_KEY = os.environ.get("ONESPAN_CALLBACK_KEY", "")

def verify_onespan_request(headers) -> bool:
    if not CALLBACK_KEY:
        return False  # never accept when unconfigured
    received = headers.get("authorization")
    if not received:
        return False
    expected = f"Basic {CALLBACK_KEY}"
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

The callback key is a shared secret, not a signature. It proves the sender knows the key, but it does not cover the body, so it cannot detect a modified payload, and there is no timestamp, so a captured request can be replayed. Serve the callback URL over HTTPS only, use a long random key, and register a new one if it might have leaked.

> **Make OneSpan webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the callback key in the `Authorization` header, queues every transaction event durably, and retries delivery to your service on a schedule you control.

## OneSpan webhook limitations and pain points

### "Basic" that is not HTTP Basic

**The Problem:** A standard Basic auth middleware rejects every callback, or decodes the key into garbage.

**Why It Happens:** OneSpan sends `Basic {callbackKey}` with the raw key. HTTP Basic expects base64 `username:password` after `Basic `, so a library that parses it fails or returns nonsense.

**Workarounds:**

* Skip Basic auth middleware on this route and compare the whole header string.
* Keep the key free of characters that are awkward in headers, such as spaces and line breaks.

**How Hookdeck Can Help:** Hookdeck's OneSpan source compares the full `Authorization` value, `Basic ` plus your key. Hookdeck answers OneSpan with a `200` either way, but a request with a missing or wrong key is recorded as failed verification and never reaches your service.

### Delivery behaviour is undocumented

**The Problem:** You cannot tell how long OneSpan waits for a response, whether it retries a failed callback, or whether callbacks arrive in order.

**Why It Happens:** The callback documentation covers registration and payloads, not delivery.

**Workarounds:**

* Respond with a `2xx` quickly and process afterwards.
* Reconcile open packages against the OneSpan Sign API on a schedule, so a missed `PACKAGE_COMPLETE` is caught.

**How Hookdeck Can Help:** Hookdeck accepts each callback and stores it, then delivers to your service with [retries](/docs/retries) you configure. You can replay any callback after a fix, so delivery no longer depends on behaviour OneSpan does not document.

### Examples that disagree with the event list

**The Problem:** A handler written from the event table misses some events.

**Why It Happens:** OneSpan's table lists `EDEPOSIT_FAILURE_ATTEMPT`, but the example payload in the same row has `"name": "EDEPOSIT_ATTEMPT_FAILURE"`. The eDeposit examples give `createdDate` as epoch milliseconds, such as `1732287648000`, while the other events use ISO 8601 strings. `PACKAGE_DISPUTE` appears in only one of the page's three event tables.

**Workarounds:**

* Accept both spellings of the eDeposit retry event until you have seen one on the wire.
* Parse `createdDate` as either a number or a string.
* Log and acknowledge any `name` you do not recognise.

**How Hookdeck Can Help:** Hookdeck stores every callback with its full body, so you can see the real `name` and date format your account sends, and [filters](/docs/filters) can match either spelling.

### Several events for one action

**The Problem:** One signer finishing a document can produce `DOCUMENT_SIGNED` for the consent agreement, `DOCUMENT_SIGNED` for each document, `SIGNER_COMPLETE` and finally `PACKAGE_COMPLETE`, all within seconds.

**Why It Happens:** Each step is its own event, and consent acceptance counts as signing a document.

**Workarounds:**

* Subscribe only to the events you act on. `PACKAGE_COMPLETE` is usually the one that matters.
* Ignore `DOCUMENT_SIGNED` events whose `documentId` is `default-consent` if you only track real documents.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can drop events you do not need, such as consent signatures, before they reach your service.

## Best practices

### Store the key, compare the header

Keep the callback key in your environment and build the expected header as `Basic ` plus the key. Never compare a decoded value, and never log the header.

### Key on the package

Use `packageId` to find the transaction in your system, and `sessionUser` to find the signer. Remember that IDs can contain `=`, `-` and `_`.

### Deduplicate deliveries

Build an idempotency key from `name`, `packageId`, `documentId` and `createdDate`, and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Fetch documents after completion, not during

On `PACKAGE_COMPLETE`, queue a job that downloads the signed documents from the API, rather than doing it in the request. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Test in the sandbox first

Register a sandbox callback, run a package through creation, signing and completion, and check every event your handler will see before you register the production URL.

## Conclusion

OneSpan Sign sends callback notifications as unsigned JSON `POST` requests, with the callback key you registered in the `Authorization` header as `Basic {callbackKey}`. Always register a long random key, compare the whole header in constant time over HTTPS, route on `name`, allow for the inconsistent eDeposit examples, and reconcile against the API, because delivery behaviour is not documented.

[Hookdeck Event Gateway](https://hookdeck.com) checks the callback key, keeps every transaction event, and retries and replays delivery to your service, so a completed signature is never lost to an outage on your side.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle OneSpan webhooks reliably in minutes.