SpareSpare Docs
GuidesAPI Reference

Manage Subscriptions

Create, list, retrieve, update, pause, and delete webhook subscriptions via the Spare API.

Webhook subscriptions are managed through the /webhooks API. Each subscription links one HTTPS URL to one product and a set of resource types. This page covers every operation: creating a subscription, listing and retrieving existing ones, updating fields, pausing without deleting, and permanent deletion.

Before you start, read How Webhooks Work for authentication, the two-signature model, and base URLs.

Conventions

  • Successful responses wrap the result in { "data": ... }.
  • Timestamps use ISO 8601 with an explicit +00:00 offset, for example 2026-09-07T10:15:30.123+00:00.
  • Subscription ids begin with wh_, event ids with evt_, and delivery ids with del_.

Get the Catalog

Before creating a subscription, call the catalog to confirm which products and resource types are available for your tenant.

curl https://api.sandbox.tryspare.ae/webhooks/catalog \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-tenant: UAE"
const res = await fetch(
  "https://api.sandbox.tryspare.ae/webhooks/catalog",
  {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "x-tenant": "UAE",
    },
  },
);
const { data } = await res.json();
import requests

res = requests.get(
    "https://api.sandbox.tryspare.ae/webhooks/catalog",
    headers={
        "Authorization": f"Bearer {access_token}",
        "x-tenant": "UAE",
    },
)
catalog = res.json()["data"]
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.sandbox.tryspare.ae/webhooks/catalog"))
    .header("Authorization", "Bearer " + accessToken)
    .header("x-tenant", "UAE")
    .GET()
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Add("x-tenant", "UAE");

var response = await client.GetAsync(
    "https://api.sandbox.tryspare.ae/webhooks/catalog");
req, _ := http.NewRequest("GET",
    "https://api.sandbox.tryspare.ae/webhooks/catalog", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Create a Subscription

POST /webhooks creates a new subscription. The request body must be signed with x-signature. See How Webhooks Work for the signing procedure.

Request fields

FieldRequiredDescription
urlYesYour HTTPS callback URL. Plain HTTP is only accepted for localhost during local development.
productYesOne product code. Only payments is subscribable today.
resourcesYesA non-empty list of resource types published for that product.
descriptionNoFree text, up to 512 characters, to help identify the subscription later.
statusNoActive (default) or Inactive.

If your receiver is not deployed yet, create the subscription with "status": "Inactive" so nothing is delivered while you finish building. Flip it to Active with a PATCH when ready.

curl -X POST https://api.sandbox.tryspare.ae/webhooks \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-tenant: UAE" \
  -H "Content-Type: application/json" \
  -H "x-signature: $X_SIGNATURE" \
  -d '{
    "url": "https://merchant.example.com/webhooks/spare",
    "description": "Payments production receiver",
    "product": "payments",
    "resources": ["payment_request", "consent", "payment"],
    "status": "Active"
  }'
const body = {
  url: "https://merchant.example.com/webhooks/spare",
  description: "Payments production receiver",
  product: "payments",
  resources: ["payment_request", "consent", "payment"],
  status: "Active",
};

const res = await fetch("https://api.sandbox.tryspare.ae/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "x-tenant": "UAE",
    "Content-Type": "application/json",
    "x-signature": buildXSignature(body, privateKey),
  },
  body: JSON.stringify(body),
});
const { data } = await res.json();
// data.id is your subscription id, e.g. "wh_01HXYZ..."
import json
import requests

body = {
    "url": "https://merchant.example.com/webhooks/spare",
    "description": "Payments production receiver",
    "product": "payments",
    "resources": ["payment_request", "consent", "payment"],
    "status": "Active",
}

res = requests.post(
    "https://api.sandbox.tryspare.ae/webhooks",
    headers={
        "Authorization": f"Bearer {access_token}",
        "x-tenant": "UAE",
        "Content-Type": "application/json",
        "x-signature": build_x_signature(body, private_key),
    },
    json=body,
)
subscription_id = res.json()["data"]["id"]
String bodyJson = """
    {
      "url": "https://merchant.example.com/webhooks/spare",
      "description": "Payments production receiver",
      "product": "payments",
      "resources": ["payment_request", "consent", "payment"],
      "status": "Active"
    }""";

var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.sandbox.tryspare.ae/webhooks"))
    .header("Authorization", "Bearer " + accessToken)
    .header("x-tenant", "UAE")
    .header("Content-Type", "application/json")
    .header("x-signature", buildXSignature(bodyJson, privateKey))
    .POST(HttpRequest.BodyPublishers.ofString(bodyJson))
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
var body = new {
    url = "https://merchant.example.com/webhooks/spare",
    description = "Payments production receiver",
    product = "payments",
    resources = new[] { "payment_request", "consent", "payment" },
    status = "Active"
};

var content = new StringContent(
    JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");

client.DefaultRequestHeaders.Add("x-signature", BuildXSignature(body, privateKey));
var response = await client.PostAsync(
    "https://api.sandbox.tryspare.ae/webhooks", content);
bodyJSON := `{
  "url": "https://merchant.example.com/webhooks/spare",
  "description": "Payments production receiver",
  "product": "payments",
  "resources": ["payment_request", "consent", "payment"],
  "status": "Active"
}`

req, _ := http.NewRequest("POST",
    "https://api.sandbox.tryspare.ae/webhooks",
    strings.NewReader(bodyJSON))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-signature", buildXSignature(bodyJSON, privateKey))

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Response 201 Created

{
  "data": {
    "id": "wh_01HXYZ...",
    "url": "https://merchant.example.com/webhooks/spare",
    "description": "Payments production receiver",
    "status": "Active",
    "product": "payments",
    "resources": ["payment_request", "consent", "payment"],
    "schemaVersion": "v1.0",
    "createdAt": "2026-09-07T10:15:30.123+00:00"
  }
}

Store the id. You need it for get, update, and delete. schemaVersion is assigned by Spare and describes the event envelope format. Do not set it in requests.

List Subscriptions

Returns every Active and Inactive subscription for your client. Deleted subscriptions are excluded.

curl https://api.sandbox.tryspare.ae/webhooks \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-tenant: UAE"
const res = await fetch("https://api.sandbox.tryspare.ae/webhooks", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "x-tenant": "UAE",
  },
});
const { data } = await res.json();
// data is an array of subscription objects
res = requests.get(
    "https://api.sandbox.tryspare.ae/webhooks",
    headers={
        "Authorization": f"Bearer {access_token}",
        "x-tenant": "UAE",
    },
)
subscriptions = res.json()["data"]
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.sandbox.tryspare.ae/webhooks"))
    .header("Authorization", "Bearer " + accessToken)
    .header("x-tenant", "UAE")
    .GET()
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
var response = await client.GetAsync(
    "https://api.sandbox.tryspare.ae/webhooks");
req, _ := http.NewRequest("GET",
    "https://api.sandbox.tryspare.ae/webhooks", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Get One Subscription

curl https://api.sandbox.tryspare.ae/webhooks/wh_01HXYZ... \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-tenant: UAE"
const res = await fetch(
  `https://api.sandbox.tryspare.ae/webhooks/${webhookId}`,
  {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "x-tenant": "UAE",
    },
  },
);
const { data } = await res.json();
res = requests.get(
    f"https://api.sandbox.tryspare.ae/webhooks/{webhook_id}",
    headers={
        "Authorization": f"Bearer {access_token}",
        "x-tenant": "UAE",
    },
)
subscription = res.json()["data"]
var request = HttpRequest.newBuilder()
    .uri(URI.create(
        "https://api.sandbox.tryspare.ae/webhooks/" + webhookId))
    .header("Authorization", "Bearer " + accessToken)
    .header("x-tenant", "UAE")
    .GET()
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
var response = await client.GetAsync(
    $"https://api.sandbox.tryspare.ae/webhooks/{webhookId}");
req, _ := http.NewRequest("GET",
    "https://api.sandbox.tryspare.ae/webhooks/"+webhookID, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

An id that does not exist, has been deleted, or belongs to another client returns 404. Spare does not distinguish between those cases.

Update a Subscription

PATCH /webhooks/{webhookId} accepts the fields you want to change. It requires an x-signature over the patch body.

You can change url, description (send null to clear it), status, product, and resources. The resources field is replaced wholesale, not merged. Send the full list you want going forward. The resulting product and resource combination must still be valid according to the catalog.

curl -X PATCH https://api.sandbox.tryspare.ae/webhooks/wh_01HXYZ... \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-tenant: UAE" \
  -H "Content-Type: application/json" \
  -H "x-signature: $X_SIGNATURE" \
  -d '{"status": "Inactive"}'
const patch = { status: "Inactive" };

const res = await fetch(
  `https://api.sandbox.tryspare.ae/webhooks/${webhookId}`,
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "x-tenant": "UAE",
      "Content-Type": "application/json",
      "x-signature": buildXSignature(patch, privateKey),
    },
    body: JSON.stringify(patch),
  },
);
const { data } = await res.json();
patch = {"status": "Inactive"}

res = requests.patch(
    f"https://api.sandbox.tryspare.ae/webhooks/{webhook_id}",
    headers={
        "Authorization": f"Bearer {access_token}",
        "x-tenant": "UAE",
        "Content-Type": "application/json",
        "x-signature": build_x_signature(patch, private_key),
    },
    json=patch,
)
updated = res.json()["data"]
String patchJson = "{\"status\": \"Inactive\"}";

var request = HttpRequest.newBuilder()
    .uri(URI.create(
        "https://api.sandbox.tryspare.ae/webhooks/" + webhookId))
    .header("Authorization", "Bearer " + accessToken)
    .header("x-tenant", "UAE")
    .header("Content-Type", "application/json")
    .header("x-signature", buildXSignature(patchJson, privateKey))
    .method("PATCH", HttpRequest.BodyPublishers.ofString(patchJson))
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
var patch = new { status = "Inactive" };
var content = new StringContent(
    JsonSerializer.Serialize(patch), Encoding.UTF8, "application/json");

client.DefaultRequestHeaders.Add(
    "x-signature", BuildXSignature(patch, privateKey));
var response = await client.PatchAsync(
    $"https://api.sandbox.tryspare.ae/webhooks/{webhookId}", content);
patchJSON := `{"status": "Inactive"}`

req, _ := http.NewRequest("PATCH",
    "https://api.sandbox.tryspare.ae/webhooks/"+webhookID,
    strings.NewReader(patchJSON))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-signature", buildXSignature(patchJSON, privateKey))

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Response 200 with the complete updated subscription.

Delete a Subscription

DELETE /webhooks/{webhookId} does not require an x-signature.

Deletion is permanent

A deleted subscription cannot be restored. Spare stops delivering events immediately. If you want to pause delivery temporarily, set status to Inactive instead.

curl -X DELETE https://api.sandbox.tryspare.ae/webhooks/wh_01HXYZ... \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-tenant: UAE"
await fetch(`https://api.sandbox.tryspare.ae/webhooks/${webhookId}`, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "x-tenant": "UAE",
  },
});
// 204 No Content on success
res = requests.delete(
    f"https://api.sandbox.tryspare.ae/webhooks/{webhook_id}",
    headers={
        "Authorization": f"Bearer {access_token}",
        "x-tenant": "UAE",
    },
)
# 204 No Content on success
var request = HttpRequest.newBuilder()
    .uri(URI.create(
        "https://api.sandbox.tryspare.ae/webhooks/" + webhookId))
    .header("Authorization", "Bearer " + accessToken)
    .header("x-tenant", "UAE")
    .DELETE()
    .build();

var response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
// 204 No Content on success
var response = await client.DeleteAsync(
    $"https://api.sandbox.tryspare.ae/webhooks/{webhookId}");
// 204 No Content on success
req, _ := http.NewRequest("DELETE",
    "https://api.sandbox.tryspare.ae/webhooks/"+webhookID, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-tenant", "UAE")

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
// 204 No Content on success

Response 204 No Content with an empty body.

Subscription Limits and Constraints

  • Two Active subscriptions per product, per client. A third Active create or update is rejected with 400. Inactive subscriptions do not count toward the limit, so setting a subscription to Inactive frees a slot while keeping the configuration.
  • One product per subscription. To receive events from a second product, create a separate subscription for it.
  • Client, product, and URL must be unique. Registering the same URL for the same product twice returns 409. For two active endpoints on the same product, use distinct paths, for example /webhooks/spare-primary and /webhooks/spare-backup.
  • Both active subscriptions receive every matching event. This is a fan-out, not load balancing. Each event is delivered to both URLs. Plan for deduplication if both point at the same system.
  • Resources must be published for the product. Naming an unknown resource type returns 400 with a message listing the available types.
  • Body allow-list is exactly url, description, status, product, and resources. Unrecognised fields (events, eventTypes, headers, encryptionKey, enabled, schemaVersion) are rejected with 400.
  • status accepts Active or Inactive on writes. To delete, use DELETE.
  • description is capped at 512 characters.
  • Pausing is not buffering. Events that occur while a subscription is Inactive are dropped, not queued. Reconcile through the REST API to cover any gap.

Error Reference

HTTPCause
400Invalid URL, unknown product, unpublished resource type, unexpected body field, description over 512 characters, invalid status, or a third Active subscription for the same product
401Bearer token missing, expired, or invalid
403x-signature missing or verification failed on a create or update
404Subscription id unknown, deleted, or belonging to another client
409A subscription with this client, product, and URL already exists
501Webhook subscriptions not available for the tenant in x-tenant

The 400 error messages identify the rejected field. When a resource type is rejected, the message lists the resource types available for the product you named.

Endpoint Summary

MethodPathAuthSuccess
GET/webhooks/catalogBearer + x-tenant200 { data }
POST/webhooksBearer + x-tenant + x-signature201 { data }
GET/webhooksBearer + x-tenant200 { data: [] }
GET/webhooks/{webhookId}Bearer + x-tenant200 { data }
PATCH/webhooks/{webhookId}Bearer + x-tenant + x-signature200 { data }
DELETE/webhooks/{webhookId}Bearer + x-tenant204
GET/webhook/jwksNone200 { keys: [...] }

On this page