# Sign document hashes from your backend

Create a signing request with the document's SHA-256 hash and the customer's passkey ID, then open the request in the signing frame. The customer reviews the document details and approves with their passkey. The frame returns the signature, while a webhook or event stream confirms completion on the backend. Batch signing uses the same flow with one approval for a set of documents.

## What you need

Before creating a request, obtain:

- A signed-in user's access token from [single sign-on](#/docs/sso), not a client-credentials (machine) token.
- The ID of one of that user's passkeys (`credentialId`). Call `GET /auth/credentials` with the user's access token and take `credentialId` from the first entry, their most recently used passkey.
- A page on an [authorised signing origin](#/docs/rp-integration-guide#3-authorise-additional-signing-origins) to show the signing frame on.

## 1. Create the signing request

Set `USER_ACCESS_TOKEN` to the token from sign-in and `CREDENTIAL_ID` to the selected passkey's ID from `GET /auth/credentials`. With `Contract.pdf` in the working directory, the following Python 3 and curl example computes its hash and submits the request:

```bash
python3 - <<'JSON' > signing-request.json
import hashlib, json, os
from pathlib import Path
print(json.dumps({
    "documentHash": hashlib.sha256(Path("Contract.pdf").read_bytes()).hexdigest(),
    "credentialId": os.environ["CREDENTIAL_ID"],
    "documentName": "Contract.pdf", "rpOrigin": "https://app.example.com", "loa": "LOA-2"
}))
JSON
curl --fail-with-body -X POST https://securysign.com/api/v2/sign/single \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @signing-request.json -o signing-request-response.json
```

```json
{
  "requestId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "pending_auth",
  "authUrl": "/api/v2/sign/auth/a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "challenge": "b64-webauthn-challenge"
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `documentHash` | string | Yes | The document's SHA-256 hash, in hex. |
| `credentialId` | string | Yes | One of the signed-in user's own passkey IDs, from `GET /auth/credentials`. |
| `documentName` | string | No | The name your user sees in the signing frame. |
| `rpOrigin` | string | No | The origin of the page that shows the signing frame. The request then works only on that page. |
| `hashAlgorithm` | string | No | The hash algorithm as an OID. Default: `2.16.840.1.101.3.4.2.1` (SHA-256). |
| `signatureFormat` | string | No | A format label stored with the request. Default: `PAdES-B-LT`. The result is a signature of the submitted hash; use the PDF signing API to embed a signature in a PDF. |
| `loa` | string | No | The level of assurance: `LOA-0`, `LOA-1` (default), `LOA-2` or `LOA-4`. |
| `callbackUrl` | string | No | Your URL that receives `sign.complete`, unsigned; see [Receive completion events](#/docs/api-hash-signing#receive-completion-events). |

Save `requestId` with the document. The signing frame retrieves the pending request by this ID, including its `authUrl` and `challenge`. The default limits are 60 requests per minute and 100 signatures per day; exceeding the applicable limit returns `429`.

## 2. Show the request to your user

Open the signing-frame URL with the returned `requestId` and the page's origin in `rpOrigin`. The frame loads the request and displays the document name, hash and assurance level for approval:

```html
<iframe id="signing-frame"
  src="https://securysign.com/#/sign-frame?requestId=a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d&rpOrigin=https%3A%2F%2Fapp.example.com"
  allow="publickey-credentials-get *"
  width="100%" height="360"></iframe>
```

The same URL can be opened as a popup with `window.open`; results are sent to its opener. The embedding or opening page must run on an [authorized signing origin](#/docs/rp-integration-guide#3-authorise-additional-signing-origins). If `rpOrigin` was supplied when creating the request, use that same origin in the frame URL, encoded with `URLSearchParams`.

## 3. Receive the signature

Listen for completion messages on the page. This handler checks both the sender's origin and the iframe window. For a popup, compare `event.source` with the window returned by `window.open`. Implement `saveSignature` and `showError` in the application:

```js
window.addEventListener("message", (event) => {
  const frame = document.getElementById("signing-frame");
  if (event.origin !== "https://securysign.com" || event.source !== frame.contentWindow) return;
  const msg = event.data;
  if (msg.type === "SSC_SIGN_COMPLETE") saveSignature(msg.requestId, msg.signatureBase64);
  if (msg.type === "SSC_SIGN_ERROR") showError(msg.error);
});
```

The `SSC_SIGN_COMPLETE` message contains `requestId`, `signatureBase64`, `documentHash`, `levelOfAssurance` and `timestamp`. Confirm completion on the backend through a signed [webhook](#/docs/api-hash-signing#receive-completion-events) or the [event stream](#/docs/api-hash-signing#watch-your-users-signing-requests-live), then save the result with the request record.

Cancelling the passkey prompt leaves the request pending. The customer can reopen it to approve later.

## Sign a batch

Under the default allowance, a batch holds up to 10 documents; the selected plan may set a different limit. Each document needs an ID from the application and its complete hash. The example below reads `Contract.pdf` and `Annex.pdf` from the working directory. Each completed signature counts toward the daily allowance:

```bash
python3 - <<'JSON' > batch-request.json
import hashlib, json, os
from pathlib import Path
print(json.dumps({
    "documents": [
        {"id": "doc-1", "hash": hashlib.sha256(Path("Contract.pdf").read_bytes()).hexdigest()},
        {"id": "doc-2", "hash": hashlib.sha256(Path("Annex.pdf").read_bytes()).hexdigest()}
    ],
    "credentialId": os.environ["CREDENTIAL_ID"]
}))
JSON
curl --fail-with-body -X POST https://securysign.com/api/v2/sign/batch \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @batch-request.json -o batch-response.json
```

Each entry in `documents` has an `id` used to match the result to the original document and a `hash` containing its full SHA-256 hexadecimal digest. `credentialId` is the signed-in customer's passkey ID. An optional `callbackUrl` receives the batch completion notification.

```json
{
  "batchId": "f0e1d2c3-b4a5-4968-8776-655443322110",
  "status": "pending_auth",
  "documentCount": 2,
  "authUrl": "/api/v2/sign/batch/f0e1d2c3-b4a5-4968-8776-655443322110/auth",
  "challenge": "b64-webauthn-challenge"
}
```

Open the returned `batchId` in the signing frame:

```text
https://securysign.com/#/sign-frame?batchId=f0e1d2c3-b4a5-4968-8776-655443322110&rpOrigin=https%3A%2F%2Fapp.example.com
```

The customer reviews the list and approves once. `SSC_BATCH_COMPLETE` includes `batchId`, an overall `status` of `completed`, `partial` or `failed`, and per-document `results`. Process each `documentId` using its own `status` and `signature`, including when the batch is only partially completed.

## Watch your user's signing requests live

A server-sent event stream reports changes to the signed-in customer's signing requests. Connect with that customer's access token:

```bash
curl -N https://securysign.com/api/v2/events \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN"
```

The stream begins with `connected`. Changes are reported at roughly three-second intervals as `signing_request_created`, `signing_request_completed` or `signing_request_failed`. Each event contains `id`, `status`, `document_hash`, `created_at`, `signed_at` and `error_message`. Track the last status for each `id` to avoid processing the same change twice.

## Receive completion events

Completion notifications can be sent to a registered webhook subscription or to a `callbackUrl` supplied on an individual request. Subscription deliveries are signed with the secret chosen at registration:

| | Subscription | `callbackUrl` |
|---|---|---|
| Set up with | `POST /v2/webhooks/register`, once | A field on each signing request |
| Signed | Yes, with `X-Webhook-Signature` | No |
| Proof of completion | Yes, after you verify the signature | No. Confirm with an authenticated call. |
| What you receive | Every request made with an access token your relying party's (RP's) OIDC client issued, and with your contact account's token | The one request it was set on |
| To set it up, you send | The access token of the account whose email is your RP's `contact_email` | Nothing |

The authenticated event stream can also be used to recover completion information after a missed delivery.

### Create a subscription

Generate a random subscription secret and store it in the server's `WEBHOOK_SECRET` configuration. Send the same value in `secret` when registering. Set `CONTACT_ACCESS_TOKEN` to the access token of the RP's contact account:

```bash
python3 - <<'JSON' > webhook-subscription.json
import json, os
print(json.dumps({
    "url": "https://app.example.com/hooks/securysign",
    "events": ["sign.complete", "batch.complete"],
    "secret": os.environ["WEBHOOK_SECRET"]
}))
JSON
curl --fail-with-body -X POST https://securysign.com/api/v2/webhooks/register \
  -H "Authorization: Bearer $CONTACT_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @webhook-subscription.json
```

Save the returned `subscriptionId`, such as `7`, and check for `status: "active"`. `GET /v2/webhooks` lists subscriptions. `DELETE /v2/webhooks/{id}` removes one and returns `{"deleted": true}`.

### Verify every delivery

Deliveries include `X-Webhook-Signature: sha256=<hex>`. Before parsing the JSON, compute HMAC-SHA256 over the exact raw request body using the subscription secret and compare it with that header:

#### Node

```javascript
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/hooks/securysign", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(req.body).digest("hex");
  const got = req.get("x-webhook-signature") ?? "";
  const ok = got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // event.event is "sign.complete" or "batch.complete"
  res.sendStatus(200);
});
```

#### Python

```python
import hashlib, hmac, os
from fastapi import FastAPI, Request, Response

app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]

@app.post("/hooks/securysign")
async def hook(request: Request):
    raw = await request.body()
    expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(request.headers.get("x-webhook-signature", ""), expected):
        return Response(status_code=401)
    event = await request.json()  # event["event"] is "sign.complete" or "batch.complete"
    return Response(status_code=200)
```

#### PHP

```php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true); // $event['event'] is "sign.complete" or "batch.complete"
http_response_code(200);
```

### Handle the events

```json
{
  "event": "sign.complete",
  "timestamp": "2026-09-26T12:00:04+00:00",
  "data": {
    "requestId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "documentHash": "a3f7c2d8e9b10000000000000000000000000000000000000000000000000000",
    "signature": "MEUCIQDxY…",
    "signedAt": "2026-09-26T12:00:04+00:00"
  }
}
```

A `batch.complete` event contains `batchId`, `signedCount`, `failedCount` and one `results` entry per document. Acknowledge the delivery with a `2xx` response and queue slower processing.

## Troubleshooting

| Status | Message | Fix |
|---|---|---|
| `400` | `documentHash and credentialId required` | Add the missing field |
| `401` | `Missing Authorization header` or `Invalid or expired token` | Send a current access token from a signed-in user |
| `401` | `Token missing subject claim` | Your client's access tokens carry no `sub`. Contact support to add the `basic` scope to your client. |
| `403` | `No RP associated with this user` (webhooks) | Sign in as the account whose email is your RP's `contact_email` |
| `400` | `rpOrigin required: …` (in the frame) | Add `rpOrigin` to the frame URL |
| `403` | `rpOrigin does not match the origin this signing request was created for (…)` | Open the frame on the origin you sent in step 1 |
| `403` | `RP origin not authorized: …` | [Authorise the origin](#/docs/rp-integration-guide#3-authorise-additional-signing-origins) |
| `409` | `This signing request is already signed. …` | Create a new request |
| `403` | `Passkey signature verification failed` | Ask your user to approve with the passkey named in `credentialId` |
| `404` | `No signing credential found for user` | Send the ID of one of this user's own passkeys |
| `404` | `Signing request not found. …` | Check `requestId`, or create a new request |
| `429` | `Rate limit exceeded…` or `Daily signing limit exceeded (…)` | Back off. The daily count resets at 00:00 UTC. |

For a missing delivery, check that the HTTPS endpoint is publicly reachable and accepts the body with a `2xx` response. For a signature mismatch, check the configured secret and confirm that the HMAC is calculated over the raw delivered bytes.

The [endpoint reference](#/docs/api-hash-signing#endpoints) contains the complete signing schemas; [Webhooks](#/docs/api-webhooks) covers subscription management. For signing in the web application, see [Hash signing](#/docs/hash-signing).

## How it works

```text
  Your backend / page            SecurySign                 User's device
  1 POST /v2/sign/single ──────► requestId (pending)
  2 open #/sign-frame?requestId ► shows the document ──────► passkey prompt
                                 completes the request ◄──── approval
  3 SSC_SIGN_COMPLETE ◄───────── signature
    webhook / event stream ◄──── confirmation to your backend
```
