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 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-signature in 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-requests
  • POST /mandate/schedule
  • PATCH /mandate/approve
  • POST /link/token/payment
  • POST /webhooks
  • PATCH /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

  1. You prepare the request payload (the JSON body you will send).
  2. You serialize that payload deterministically, using the rules below.
  3. You sign the serialized string with your EC private key (ES256).
  4. You send the original JSON body plus x-signature: <detached-jws>.
  5. 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

ItemValue
Signature formatDetached JWS
AlgorithmES256 (ECDSA with the P-256 curve and SHA-256)
PayloadCanonical serialized request body
Private keyPKCS8 PEM from the Spare dashboard (-----BEGIN PRIVATE KEY-----)
HTTP headerx-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

  1. Single-line JSON. No indentation, extra whitespace, or line breaks.
  2. Drop null and undefined. Omit any property whose value is null or undefined, at every nesting level.
  3. Sort keys alphabetically. Sort every object's keys lexicographically (localeCompare), at every nesting level.
  4. Numbers. Keep numbers inside objects as JSON numbers (normal JSON.stringify behaviour). If the value you pass in is a top-level number primitive, round to 5 decimal places and strip trailing zeros (for example 1000 becomes "1000", 1000.5 becomes "1000.5"). Payment amount fields in the API are usually decimal strings, so this case is uncommon.
  5. Arrays. Preserve element order. Serialize each element with the same rules, then emit a JSON array.

Steps Summary

  1. Drop null / undefined properties.
  2. Sort object keys alphabetically at every level.
  3. Serialize nested values with the same rules.
  4. Emit a single-line JSON string.
  5. 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

  1. Build the request object (this is also the HTTP body).
  2. serializedPayload = serializePayload(requestObject).
  3. Sign the UTF-8 bytes of serializedPayload with ES256.
  4. Emit detached JWS: protectedHeader..signature.
  5. Send the original JSON body with x-signature set 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.json
import { 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

MistakeResult
Signing a different object than the HTTP bodySignature rejected
Using JSON.stringify without sorting keysSignature rejected when key order differs
Keeping null fields in the signed stringSignature rejected
Including the payload in the JWS (not detached)Signature rejected
Encoding the signature as DER instead of raw R||SSignature 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; Spare recomputes and compares byte-for-byte.
  • Sign on your server only; the private key never belongs in client code.

On this page