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 was not altered in transit. You cryptographically sign the request payload and send a detached ES256 JWS in the x-signature header. Spare verifies that signature before processing the request.
This page covers:
- Which APIs require a signature
- How verification works end to end
- The mandatory serialization contract
- How to build
x-signaturein your own backend
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 these write requests:
POST /payment-requestsPOST /mandate/schedulePATCH /mandate/approvePOST /link/token/paymentPOST /webhooksPATCH /webhooks/{webhookId}
Read requests (list, get) and authentication do not need it. Each endpoint's API Reference marks whether x-signature is required. Spare may add more signed endpoints later; the reference is the source of truth.
High-Level Flow
- You prepare the request payload (the JSON body you will send).
- You serialize that payload deterministically, using the rules below.
- You sign the serialized string with your EC private key (ES256).
- You send the original JSON body plus
x-signature: <detached-jws>. - Spare re-serializes the body with the same rules, verifies the signature with your registered public key, and rejects the request if verification fails.
What You Sign
Sign the request payload for the endpoint, then send that same object as the HTTP JSON body.
For POST /payment-requests, sign the same JSON object you send as the HTTP body. There is no enclosing request field. Other signed endpoints also sign their request body; see each endpoint's API Reference.
Give the same object to your signing function and to the HTTP body. If you change a field after you sign, Spare rejects the request.
Cryptographic Details
| Item | Value |
|---|---|
| Signature format | Detached JWS |
| Algorithm | ES256 (ECDSA with the P-256 curve and SHA-256) |
| Payload | Canonical serialized request body |
| Private key | PKCS8 PEM from the Spare dashboard (-----BEGIN PRIVATE KEY-----) |
| HTTP header | x-signature: <detached-jws> |
Detached JWS compact form:
BASE64URL({"alg":"ES256"})..BASE64URL(signature)The middle segment is empty, so the value has two dots. You sign over the canonical JSON bytes, but you do not put those bytes in the JWS string. Spare reconstructs the payload independently for verification.
Serialization Contract
Spare recomputes the canonical string from your body and verifies the signature against it. Client and server must serialize identically. Any deviation (key order, retained nulls, different number formatting) causes a mismatch.
Principles
- Determinism: the same payload must always produce the same serialized string.
- Consistency: you and Spare must serialize with the same rules.
- Order and encoding: property order and value formatting are part of the contract.
Rules
- Single-line JSON. No indentation, extra whitespace, or line breaks.
- Drop null and undefined. Omit any property whose value is
nullorundefined, at every nesting level. - Sort keys alphabetically. Sort every object's keys lexicographically (
localeCompare), at every nesting level. - Numbers. Keep numbers inside objects as JSON numbers (normal
JSON.stringifybehaviour). If the value you pass in is a top-level number primitive, round to 5 decimal places and strip trailing zeros (for example1000becomes"1000",1000.5becomes"1000.5"). Payment amount fields in the API are usually decimal strings, so this case is uncommon. - Arrays. Preserve element order. Serialize each element with the same rules, then emit a JSON array.
Steps Summary
- Drop
null/undefinedproperties. - Sort object keys alphabetically at every level.
- Serialize nested values with the same rules.
- Emit a single-line JSON string.
- Sign that string.
Reference Serializer
Use this as the reference implementation. Other languages must match its output byte-for-byte:
const serializePayload = (json) => {
if (Array.isArray(json)) {
return JSON.stringify(
json.map((item) =>
typeof item === "object" && item !== null
? JSON.parse(serializePayload(item))
: item,
),
);
} else if (json !== null && typeof json === "object") {
return JSON.stringify(
Object.keys(json)
.sort((key1, key2) => key1.localeCompare(key2))
.filter((key) => json[key] !== null && json[key] !== undefined)
.reduce((acc, key) => {
if (typeof json[key] === "object")
acc[key] = JSON.parse(serializePayload(json[key]));
else acc[key] = json[key];
return acc;
}, {}),
);
}
if (typeof json === "number") {
return Number.parseFloat(json.toFixed(5)).toString();
}
return json;
};Build the Signature
Given the serialized string and your PKCS8 private key, produce a detached ES256 JWS for x-signature.
Steps
- Build the request object (this is also the HTTP body).
serializedPayload = serializePayload(requestObject).- Sign the UTF-8 bytes of
serializedPayloadwith ES256. - Emit detached JWS:
protectedHeader..signature. - Send the original JSON body with
x-signatureset to that value.
cURL cannot compute the signature. Build X_SIGNATURE on your backend (see the other tabs), then attach it. The TypeScript tab uses jose (FlattenedSign), which matches the reference serializer above.
# Build X_SIGNATURE on your backend, 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 { FlattenedSign, importPKCS8 } from "jose";
const loadPrivateKey = async (privateKeyPem: string) =>
importPKCS8(privateKeyPem, "ES256");
const generateDetachedJWS = async (
serializedPayload: string,
privateKey: string,
) => {
const privateKeyPem = await loadPrivateKey(privateKey);
const jwt = await new FlattenedSign(
new TextEncoder().encode(serializedPayload),
)
.setProtectedHeader({ alg: "ES256" })
.sign(privateKeyPem);
return `${jwt.protected}..${jwt.signature}`;
};
const xSignature = await generateDetachedJWS(
serializePayload(requestBody),
process.env.SPARE_PRIVATE_KEY_PEM!,
);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(serialized_payload.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(serializedPayload.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(serializedPayload));
// 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(serializedPayload))
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)The ECDSA signature must be raw R||S (IEEE P1363), not DER. JWS ES256 requires that encoding.
Example
Original Payload
Sign this object, and send the same object as the HTTP body:
{
"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" }
},
"unusedOptionalField": null
}Serialized Payload
After dropping nulls and sorting keys, the string you sign is:
{"creditorAccount":{"identification":"10000109010101","name":"Mario International","schemeName":"IBAN"},"creditorReference":"CREDITOR789012","creditorType":"MERCHANT","instructions":{"amount":{"amount":"1.50","currency":"AED"}},"merchantReference":"EXT123456789","purpose":"ACM","type":"SingleInstantPayment"}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>
{
"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" }
}
}Error Handling
If the signature is missing or invalid, Spare rejects the request (typically 403) with a message such as invalid or missing signature. Fix the payload you sign, the serialization, or the key, then retry.
Production Responsibility
In production you must implement serialization and signing in your own systems. Sandbox helpers (for example a Postman pre-request script that sets x-signature from appPrivateKeyPem) exist only to simplify testing. They are not a substitute for a production implementation.
Your dashboard API-key flow downloads the EC private key in PKCS8 PEM form. Store it in a secrets manager and load it only on the server that builds x-signature.
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 a different object than the HTTP body | Signature rejected |
Using JSON.stringify without sorting keys | Signature rejected when key order differs |
Keeping null fields in the signed string | Signature rejected |
| Including the payload in the JWS (not detached) | Signature rejected |
| Encoding the signature as DER instead of raw R||S | 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; Spare recomputes and compares byte-for-byte. - Sign on your server only; the private key never belongs in client code.