SpareSpare Docs
GuidesAPI Reference
Quick Start

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-requests
  • POST /mandate/schedule
  • PATCH /mandate/approve
  • POST /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

ItemValue
AlgorithmES256 (ECDSA with the P-256 curve and SHA-256)
Private keyA PKCS8 PEM key from the Spare dashboard. It starts with -----BEGIN PRIVATE KEY-----
Signature formatA detached JWS: base64url({"alg":"ES256"})..base64url(signature)
HTTP headerx-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.

  1. Sort keys of every object alphabetically, at each level.
  2. Drop fields that are null or undefined.
  3. If a top-level value is a number, round it to 5 decimal places and strip trailing zeros (for example 1000.00 becomes "1000"). Amount fields are decimal strings, so this rarely applies.
  4. Keep numbers inside objects as JSON numbers (the normal JSON.stringify result).
  5. 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.json
import { 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

MistakeResult
Signing the wrong payload (not the canonical request)401, signature rejected
Using JSON.stringify without sorting keys401 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||S401, signature rejected
Signing in the app instead of the serverPrivate key exposed in the client bundle

Key takeaways

  • x-signature is 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/undefined dropped; the server recomputes and compares byte-for-byte.
  • Sign on your server only; the private key never belongs in client code.

On this page