Signing Worker Protocol
Authentication
How your signing worker verifies that each SWP/1 request comes from SignatureAPI, for an account it serves, unchanged
Private beta. Qualified signatures are available on request. Request access and we enable them for your account. SWP/1 is in beta and may change.
Your worker signs in your signers’ names, so it must accept requests from SignatureAPI only, and only for the accounts it serves. Every request carries a short-lived token that SignatureAPI signs for that one request. Your worker verifies it with SignatureAPI’s public keys. There are no shared secrets to store or rotate.
Request headers
Every operation is a POST to {worker URL}/v1/{operation} with a JSON body and these headers:
| Header | Value |
|---|---|
Authorization | Bearer <token>. See The token. |
Content-Digest | sha-256=:<base64 SHA-256 of the raw body>: |
Idempotency-Key | <session.id>/<operation>/<session.attempt>, plus /resume when the request carries callback.code. See Idempotency. |
SignatureAPI-Protocol | swp/1 |
GET /v1/health carries no token and needs no authentication.
The token
The token is a JWT signed with ES256. Any JWT library can verify it.
| Claim or header | Value | What your worker does |
|---|---|---|
alg, kid | ES256, key ID in the header | Accept only ES256. Look up kid in the key set. |
iss | https://api.signatureapi.com | Verify it exactly. |
aud | The worker URL as connected, without a trailing slash | Verify it exactly. A token for another worker must not verify at yours. |
sub | The signer’s recipient ID | Optionally compare it with signer.id. |
iat, exp | exp is 5 minutes after iat | Reject expired tokens. Allow a little clock skew. |
jti | Unique per request | Reject one you have already accepted. See Replays. |
connection_token | The calling account’s connection token for the session’s mode | Refuse it unless it is on your list. |
swp.operation | The operation name | Compare it with the path. |
swp.session | The session ID | Compare it with session.id in the body. |
swp.body_sha256 | Base64 SHA-256 of the raw body | Compare it with the bytes you received, before parsing them. |
A decoded token:
{
"iss": "https://api.signatureapi.com",
"aud": "https://signer.example.com",
"sub": "re_00ZeZjRaXCgT30n1eBx53N",
"iat": 1787590800,
"exp": 1787591100,
"jti": "5b0f3c2e-8d1a-4f6b-9c7e-2a3d4e5f6a7b",
"connection_token": "swpct_xulVJ9y9r1RWzRVwMZSzC4AMxZPCvI7W",
"swp": {
"session": "sgs_YFobK1NDktAuz0wzplR4OP",
"operation": "credential",
"body_sha256": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
}
}
Connection tokens
A connection token identifies one SignatureAPI account in one mode, without revealing anything about the account. It starts with swpct_. An account’s test and live tokens differ, so a test token never authorizes live signing.
Account owners find their tokens in the Dashboard and send them to you. Keep a list of the tokens you accept, list them in your health response, and refuse any request whose connection_token isn’t on the list:
{ "error": "invalid_token", "error_description": "connection token not allowed" }
Without this check, any SignatureAPI account that enters your URL could send signing requests to your worker.
Public keys
SignatureAPI publishes its public keys as a JSON Web Key Set at https://api.signatureapi.com/v1/signing-protocol/jwks. It needs no authentication.
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "out19VTBbggxibTqZoC-3LL3QU8sJZOt_HEQ5DH-FHg",
"y": "tQOFrfPVCp-tzwhY_0KN1VL2OeXePqJ6RrilsE13qJU",
"kid": "WyPngh2UWAaPvkD8",
"use": "sig",
"alg": "ES256"
}
]
}
Cache the set, and fetch it again when a token has an unknown kid. Don’t hard-code a key.
Verify a request
Check in this order, and stop at the first failure:
- Protocol.
SignatureAPI-Protocolisswp/1. If not, answer400. - Token. Verify the signature,
alg,iss,aud,iatandexp. - Bindings.
swp.operationmatches the path,swp.body_sha256matches the SHA-256 of the raw body, andswp.sessionmatchessession.id. - Connection token.
connection_tokenis on your list. - Replay. You haven’t accepted this
jtibefore. Remember it now. - Content-Digest. The header matches the same hash. If not, answer
400. - Idempotency-Key. It is present. If not, answer
400witherror_descriptionmissing Idempotency-Key.
Refuse a failure in steps 2 to 5 with 401, "error": "invalid_token", and the failing check in error_description. The Dashboard shows that text when a connection check fails.
The order matters. A request without an Idempotency-Key is still authenticated first. The connection checks send exactly such a request and expect 400, and they send a token for another worker and expect 401.
Example in Node.js
This example uses the jose library, whose key set helper caches keys and refetches on an unknown kid.
import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
const WORKER_URL = "https://signer.example.com"; // Exactly the URL the account connects
const CLOCK_TOLERANCE_S = 60;
// Comma-separated swpct_ tokens of the accounts this worker serves
const ALLOWED_CONNECTION_TOKENS = new Set(
(process.env.CONNECTION_TOKENS ?? "").split(",").map((t) => t.trim()).filter(Boolean),
);
const JWKS = createRemoteJWKSet(new URL("https://api.signatureapi.com/v1/signing-protocol/jwks"));
const seenJtis = new Map(); // jti -> forget-after (ms). Use a shared store across instances.
const refuse = (status, error, error_description) => ({ status, error, error_description });
// rawBody is the request body as received (a Buffer), before JSON parsing
export async function authenticate(headers, rawBody, operation) {
if (headers["signatureapi-protocol"] !== "swp/1") {
return refuse(400, "invalid_request", "missing SignatureAPI-Protocol");
}
const token = (headers["authorization"] ?? "").replace(/^Bearer /, "");
let claims;
try {
({ payload: claims } = await jwtVerify(token, JWKS, {
algorithms: ["ES256"],
issuer: "https://api.signatureapi.com",
audience: WORKER_URL,
requiredClaims: ["iat", "exp", "jti"],
clockTolerance: CLOCK_TOLERANCE_S,
maxTokenAge: "5m",
}));
} catch (error) {
return refuse(401, "invalid_token", error.message);
}
const bodySha256 = createHash("sha256").update(rawBody).digest("base64");
if (claims.swp?.operation !== operation) {
return refuse(401, "invalid_token", "operation mismatch");
}
if (claims.swp?.body_sha256 !== bodySha256) {
return refuse(401, "invalid_token", "body_sha256 mismatch");
}
let body;
try {
body = JSON.parse(rawBody);
} catch {
return refuse(400, "invalid_request", "body is not JSON");
}
if (claims.swp?.session !== body.session?.id) {
return refuse(401, "invalid_token", "session mismatch");
}
if (!ALLOWED_CONNECTION_TOKENS.has(claims.connection_token)) {
return refuse(401, "invalid_token", "connection token not allowed");
}
const now = Date.now();
if ((seenJtis.get(claims.jti) ?? 0) > now) {
return refuse(401, "invalid_token", "replayed jti");
}
// Remember the jti for as long as the token could still pass the exp check
seenJtis.set(claims.jti, (claims.exp + CLOCK_TOLERANCE_S) * 1000);
if (headers["content-digest"] !== `sha-256=:${bodySha256}:`) {
return refuse(400, "invalid_request", "Content-Digest mismatch");
}
if (!headers["idempotency-key"]) {
return refuse(400, "invalid_request", "missing Idempotency-Key");
}
return { body, bodySha256, idempotencyKey: headers["idempotency-key"] };
}
Replays
Remember each accepted jti until the token’s exp plus your clock tolerance. After that, the token fails the exp check anyway. Record a jti only once the request passes every token check, so a rejected request can’t use up a legitimate one.
Each HTTP attempt carries a new token. When SignatureAPI retries a call, it signs a new token with a new jti over the same body, with the same Content-Digest and Idempotency-Key. Your jti check stops replays. Your idempotency handling stops a retry from running the act twice.
Responses are not signed
Your responses need no signature. A returned signature either verifies over the digest SignatureAPI sent, against the certificate it already holds, or the session fails. See What SignatureAPI checks.