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:00offset, for example2026-09-07T10:15:30.123+00:00. - Subscription ids begin with
wh_, event ids withevt_, and delivery ids withdel_.
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
| Field | Required | Description |
|---|---|---|
url | Yes | Your HTTPS callback URL. Plain HTTP is only accepted for localhost during local development. |
product | Yes | One product code. Only payments is subscribable today. |
resources | Yes | A non-empty list of resource types published for that product. |
description | No | Free text, up to 512 characters, to help identify the subscription later. |
status | No | Active (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 objectsres = 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 successres = requests.delete(
f"https://api.sandbox.tryspare.ae/webhooks/{webhook_id}",
headers={
"Authorization": f"Bearer {access_token}",
"x-tenant": "UAE",
},
)
# 204 No Content on successvar 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 successvar response = await client.DeleteAsync(
$"https://api.sandbox.tryspare.ae/webhooks/{webhookId}");
// 204 No Content on successreq, _ := 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 successResponse 204 No Content with an empty body.
Subscription Limits and Constraints
- Two
Activesubscriptions per product, per client. A thirdActivecreate or update is rejected with400.Inactivesubscriptions do not count toward the limit, so setting a subscription toInactivefrees 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-primaryand/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
400with a message listing the available types. - Body allow-list is exactly
url,description,status,product, andresources. Unrecognised fields (events,eventTypes,headers,encryptionKey,enabled,schemaVersion) are rejected with400. statusacceptsActiveorInactiveon writes. To delete, useDELETE.descriptionis capped at 512 characters.- Pausing is not buffering. Events that occur while a subscription is
Inactiveare dropped, not queued. Reconcile through the REST API to cover any gap.
Error Reference
| HTTP | Cause |
|---|---|
400 | Invalid URL, unknown product, unpublished resource type, unexpected body field, description over 512 characters, invalid status, or a third Active subscription for the same product |
401 | Bearer token missing, expired, or invalid |
403 | x-signature missing or verification failed on a create or update |
404 | Subscription id unknown, deleted, or belonging to another client |
409 | A subscription with this client, product, and URL already exists |
501 | Webhook 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
| Method | Path | Auth | Success |
|---|---|---|---|
GET | /webhooks/catalog | Bearer + x-tenant | 200 { data } |
POST | /webhooks | Bearer + x-tenant + x-signature | 201 { data } |
GET | /webhooks | Bearer + x-tenant | 200 { data: [] } |
GET | /webhooks/{webhookId} | Bearer + x-tenant | 200 { data } |
PATCH | /webhooks/{webhookId} | Bearer + x-tenant + x-signature | 200 { data } |
DELETE | /webhooks/{webhookId} | Bearer + x-tenant | 204 |
GET | /webhook/jwks | None | 200 { keys: [...] } |