Request Signing
Sign write requests with a detached ES256 JWS in the x-signature header, where the market requires it.
Some write requests must carry a request signature so Spare can verify the call came from your backend and the payload wasn't altered in transit. You send it as a detached ES256 JWS in the x-signature header.
Sign on your server only
Compute the signature on your backend. Never ship the private key in a mobile app, browser, or any client-side bundle.
When it's required
The x-signature header is required on payment write requests:
POST /payment-requestsPOST /mandate/schedulePATCH /mandate/approvePOST /link/token/payment
Read requests (list, get) and authentication don't need it. Each endpoint's reference marks whether x-signature is required.
What you sign
Sign the request payload for the endpoint, then send the full body as JSON.
For POST /payment-requests the payload is the inner request object, and the body wraps it:
{
"request": { "...": "this is what you sign" }
}Pass the request object to your signing function; send the full body (including the request key) as the JSON body. Other signed endpoints sign their own request body, see each endpoint's API Reference.
Algorithm and key
| Item | Value |
|---|---|
| Algorithm | ES256 (ECDSA with the P-256 curve and SHA-256) |
| Private key | A PKCS8 PEM key from the Spare dashboard. It starts with -----BEGIN PRIVATE KEY----- |
| Signature format | A detached JWS: base64url({"alg":"ES256"})..base64url(signature) |
| HTTP header | x-signature: <detached-jws> |
A detached JWS has an empty payload segment, so it has two dots: header..signature. You compute the signature over the canonical JSON bytes, but you don't include those bytes in the JWS.
Canonical serialization
The server recomputes the signature from your payload, so your serialization must match its byte-for-byte. If one byte differs, the request is rejected with 401.
- Sort keys of every object alphabetically, at each level.
- Drop fields that are
nullorundefined. - If a top-level value is a number, round it to 5 decimal places and strip trailing zeros (for example
1000.00becomes"1000"). Amount fields are decimal strings, so this rarely applies. - Keep numbers inside objects as JSON numbers (the normal
JSON.stringifyresult). - Serialize each array element, then join them into a JSON array.
Reference serializer (the other languages follow the same rules):
function serialize(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(serialize).join(",")}]`;
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
const entries = Object.keys(obj)
.filter((k) => obj[k] != null)
.sort()
.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`);
return `{${entries.join(",")}}`;
}
return JSON.stringify(value); // strings, numbers, booleans
}Build the signature
Given canonical (the serialized string above) and your PKCS8 private key, produce the x-signature value:
# cURL can't compute the signature; build X_SIGNATURE on your backend (see the
# other tabs), then send the request. SPARE_BASE_URL is your market's base URL.
curl -X POST "$SPARE_BASE_URL/payment-requests" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "x-signature: $X_SIGNATURE" \
--data @request.jsonimport { createSign } from "node:crypto";
const b64url = (i: string | Buffer) => Buffer.from(i).toString("base64url");
const header = b64url('{"alg":"ES256"}');
const payload = b64url(canonical);
const signature = createSign("SHA256")
.update(`${header}.${payload}`)
// ieee-p1363 = raw 64-byte R||S, which JWS ES256 requires (not DER).
.sign({ key: privateKeyPem, dsaEncoding: "ieee-p1363" }, "base64url");
const xSignature = `${header}..${signature}`;import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
key = serialization.load_pem_private_key(private_key_pem, password=None)
header = b64url(b'{"alg":"ES256"}')
payload = b64url(canonical.encode())
der = key.sign(f"{header}.{payload}".encode(), ec.ECDSA(hashes.SHA256()))
r, s = utils.decode_dss_signature(der) # DER -> raw R||S
x_signature = f"{header}..{b64url(r.to_bytes(32, 'big') + s.to_bytes(32, 'big'))}"import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.Signature;
import java.util.Base64;
Base64.Encoder b64 = Base64.getUrlEncoder().withoutPadding();
String header = b64.encodeToString("{\"alg\":\"ES256\"}".getBytes(StandardCharsets.UTF_8));
String payload = b64.encodeToString(canonical.getBytes(StandardCharsets.UTF_8));
// P1363 gives the raw R||S signature JWS expects (Java 9+).
Signature ecdsa = Signature.getInstance("SHA256withECDSAinP1363Format");
ecdsa.initSign(privateKey);
ecdsa.update((header + "." + payload).getBytes(StandardCharsets.US_ASCII));
String xSignature = header + ".." + b64.encodeToString(ecdsa.sign());using System;
using System.Security.Cryptography;
using System.Text;
static string B64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var header = B64Url(Encoding.UTF8.GetBytes("{\"alg\":\"ES256\"}"));
var payload = B64Url(Encoding.UTF8.GetBytes(canonical));
// ECDsa.SignData returns IEEE P1363 (raw R||S), which JWS ES256 requires.
byte[] sig = ecdsa.SignData(
Encoding.ASCII.GetBytes($"{header}.{payload}"), HashAlgorithmName.SHA256);
var xSignature = $"{header}..{B64Url(sig)}";import (
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(canonical))
digest := sha256.Sum256([]byte(header + "." + payload))
r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:])
if err != nil {
panic(err)
}
raw := make([]byte, 64) // raw R||S
r.FillBytes(raw[:32])
s.FillBytes(raw[32:])
xSignature := header + ".." + base64.RawURLEncoding.EncodeToString(raw)Example request
POST /payment-requests HTTP/1.1
Host: api.sandbox.tryspare.ae
Authorization: Bearer <access_token>
Content-Type: application/json
x-tenant: UAE
x-signature: eyJhbGciOiJFUzI1NiJ9..<signature>
{
"request": {
"type": "SingleInstantPayment",
"creditorType": "MERCHANT",
"creditorReference": "CREDITOR789012",
"creditorAccount": { "schemeName": "IBAN", "identification": "10000109010101", "name": "Mario International" },
"purpose": "ACM",
"merchantReference": "EXT123456789",
"instructions": { "amount": { "amount": "1.50", "currency": "AED" } }
}
}Verifying Spare's signatures
Spare signs the tokens and webhook payloads it sends you. Fetch the platform's public keys to verify them:
GET /auth/.well-known/jwks.json
See Authentication for the JWKS reference.
Common mistakes
| Mistake | Result |
|---|---|
| Signing the wrong payload (not the canonical request) | 401, signature rejected |
Using JSON.stringify without sorting keys | 401 on some payloads (key order differs) |
| Including the payload in the JWS (not detached) | 401, signature rejected |
| Encoding the signature as DER instead of raw R||S | 401, signature rejected |
| Signing in the app instead of the server | Private key exposed in the client bundle |
Key takeaways
x-signatureis a detached ES256 JWS over the canonical bytes of the request payload, sent on write requests that require it.- Serialize with sorted keys and
null/undefineddropped; the server recomputes and compares byte-for-byte. - Sign on your server only; the private key never belongs in client code.