Every subscription endpoint: request and response bodies, validation, and errors.
Every endpoint below requires account as a query parameter, the same as the Read API. See Generating an Auth Token for how you obtain a token. For the three event types you can subscribe to, see Webhooks Overview & Event Types. For how to verify a delivery once it arrives, see Verifying Signatures.
Create
POST /v4/retailers/subscriptions?account={account}
{
"callback_url": "https://hooks.example.com/joor",
"events": ["product.created", "product.updated", "product.deleted"]
}201 Created:
{
"data": {
"id": 91,
"retailer_account_id": 40975,
"callback_url": "https://hooks.example.com/joor",
"events": ["product.created", "product.updated", "product.deleted"],
"active": true,
"status": "active",
"created": "2026-08-08T15:00:00Z",
"modified": "2026-08-08T15:00:00Z",
"secret_key": "whsec_9f3a1c..."
},
"errors": []
}secret_key is returned here and nowhere else. No read endpoint ever returns it. If you lose it, rotate.
The subscription object
| Field | Type | Notes |
|---|---|---|
id | integer | Path segment for every other subscription call |
retailer_account_id | integer | The account this subscription belongs to. Matches retailer_account_id in the payload |
callback_url | string | HTTPS and public domains |
events | array of string | Subset of the three event types. Deduplicated, order preserved. Replaced outright by PATCH, never merged |
active | boolean | What you set through PATCH |
status | string | active and suspended are documented here. Whether other values exist is not stated; ask your JOOR contact for the full enum |
created, modified | timestamp | |
suspended_at, suspend_reason | timestamp, string | Present only while suspended |
stats | object | Returned by "Retrieve" only |
secret_key | string | Returned by create and rotate only, exactly once |
Validation
| Rule | Detail |
|---|---|
callback_url | Required. Must start with https://. Private, loopback and link local addresses are rejected, and the address is re checked on every send. URLs with embedded credentials (user:pass@host) are rejected |
events | Required. Non empty subset of product.created, product.updated, product.deleted. An empty list is rejected |
| Unknown fields | Rejected with 400. Do not send anything not listed here |
| Per account cap | 5 live subscriptions |
| Duplicates | An exact duplicate of an existing live callback_url returns 409. A duplicate is almost always a client retry bug rather than intent |
{
"data": [],
"errors": [ { "status": "VALIDATION_ERROR", "message": "callback_url must be an https URL.", "details": {} } ]
}The re check of the callback address on every send is worth designing around: a DNS change that moves your hostname onto a private address will start failing deliveries even though nothing about the subscription changed.
List
GET /v4/retailers/subscriptions?account={account}
Keyset paginated, 20 per page maximum. Never returns secret_key.
{
"data": [
{
"id": 91,
"retailer_account_id": 40975,
"callback_url": "https://hooks.example.com/joor",
"events": ["product.updated", "product.deleted"],
"active": true,
"status": "active",
"created": "2026-08-08T15:00:00Z",
"modified": "2026-08-08T15:00:00Z"
}
],
"errors": [],
"pagination": { "limit": 20, "next_cursor": null, "next_page": null }
}Retrieve
GET /v4/retailers/subscriptions/{id}?account={account}
Adds stats, which is your first stop when something looks wrong:
{
"data": {
"id": 91,
"retailer_account_id": 40975,
"callback_url": "https://hooks.example.com/joor",
"events": ["product.updated", "product.deleted"],
"active": true,
"status": "suspended",
"suspended_at": "2026-08-08T14:45:00Z",
"suspend_reason": "50 consecutive failures (last: read timeout)",
"created": "2026-08-01T15:00:00Z",
"modified": "2026-08-08T14:45:00Z",
"stats": {
"sent_24h": 812,
"failed_24h": 61,
"expired_24h": 50,
"consecutive_failures": 50,
"last_success_at": "2026-08-08T13:30:00Z"
}
},
"errors": []
}stats field | Meaning |
|---|---|
sent_24h | Deliveries sent in the last 24 hours |
failed_24h | Failed attempts in the last 24 hours |
expired_24h | Deliveries that exhausted all four attempts in the last 24 hours. This is the number that means data loss, so alert on it |
consecutive_failures | Current run. 50 suspends |
last_success_at | Timestamp of the last 2xx from your endpoint |
suspended_at and suspend_reason appear only when the subscription is suspended.
A subscription belonging to another account returns 404. A caller that is not a retailer account returns 403.
Read events here before you PATCH it. Since PATCH replaces the list rather than merging into it, this is where you get the current value to build the new one from.
Update
PATCH /v4/retailers/subscriptions/{id}?account={account}
Any subset of callback_url, events, active. At least one is required, or you get 400. secret_key and status are not editable here; use rotate and resume.
{ "callback_url": "https://hooks.example.com/joor/v2", "active": true }events is a full replacement, not a merge. PATCH deduplicates the list you send, keeping its order, then replaces the stored value outright. An empty list is rejected.
So always send the complete set you want. Sending {"events": ["product.created"]} to a subscription that was listening to all three leaves it listening for creates only, and your deletions stop arriving with no error and no warning. To opt an existing subscription into create events, send all three:
{ "events": ["product.created", "product.updated", "product.deleted"] }Reactivating a paused subscription recovers the gap. Setting active: true schedules a visibility comparison and a catch up, because nothing is captured while a subscription is inactive.
Delete
DELETE /v4/retailers/subscriptions/{id}?account={account}
204 No Content. Idempotent. Stops all future deliveries and takes the delivery history with it: a subsequent GET of the subscription or its deliveries returns 404.
If you are debugging a delivery problem, pull GET .../deliveries before you delete anything. The history does not survive.
Rotate the signing secret
POST /v4/retailers/subscriptions/{id}/rotate-secret?account={account}
{ "data": { "id": 91, "secret_key": "whsec_2b71e9..." }, "errors": [] }Returned once. See Verifying Signatures, "Rotating without downtime".
Send a test event
POST /v4/retailers/subscriptions/{id}/test?account={account}
Sends a synthetic, signed product.updated to your callback immediately.
{ "data": { "delivered": true, "http_status": 200, "latency_ms": 143, "delivery_id": "test-6c1f..." }, "errors": [] }If it fails, error carries the reason. Note that this page names a singular error here while the envelope carries a plural errors array, and the failure shape is not shown. Read both defensively, and ask your JOOR contact for the exact shape of a failed test delivery.
Use this during onboarding to prove your signature verification works before anything real depends on it. It is not recorded as a delivery and does not count towards your failure counters.
Resume after a suspension
POST /v4/retailers/subscriptions/{id}/resume?account={account}
{ "data": { "id": 91, "status": "active", "resumed_at": "2026-08-08T16:00:00Z", "mode": "catch_up" }, "errors": [] }mode | Meaning |
|---|---|
catch_up | The suspended window is being replayed to you, paced |
full_resync_advised | Too much changed to trickle out. Nothing is queued. Re walk the catalog with the bulk export instead |
There is a 60 minute cooldown between resumes, so a flapping endpoint cannot loop. What a resume inside that cooldown returns is not documented; treat any non 200 from resume as "wait and try again" rather than "retry now", and ask your JOOR contact to confirm.
List delivery attempts
GET /v4/retailers/subscriptions/{id}/deliveries?account={account}
Newest first, 20 per page maximum, bounded by 90 day retention. status is optional and one of pending, sent, failed, expired.
{
"data": [
{
"delivery_id": "5f2b7c4e-9a1d-4f3e-8c2b-1d0e5a7c9b31",
"product_id": 12345,
"event": "product.updated",
"changes": ["skus", "images"],
"status": "sent",
"attempts": 1,
"http_status": 200,
"delivered_at": "2026-08-08T14:58:00Z"
},
{
"delivery_id": "a1c9e0d2-...",
"product_id": 12222,
"event": "product.updated",
"changes": ["core"],
"status": "expired",
"attempts": 4,
"http_status": null,
"last_error": "read timeout",
"last_attempt_at": "2026-08-08T14:40:00Z"
}
],
"errors": [],
"pagination": {
"limit": 20,
"next_cursor": "WzE3ODYxMjM0NTYwMDAwMDAsMTIyMjJd",
"next_page": "/v4/retailers/subscriptions/91/deliveries?account=40975&limit=20&cursor=WzE3ODYxMjM0NTYwMDAwMDAsMTIyMjJd"
}
}| Field | Notes |
|---|---|
delivery_id | Matches the X-JOOR-Delivery-Id header and the delivery_id in the body |
product_id | The product the notification concerned |
event | One of the three event types |
changes | The sections that were embedded. Absent for a product.deleted |
status | pending, sent, failed, expired |
attempts | 1 to 4. At 4 with status: "expired" the notification was dropped |
http_status | What your endpoint answered, or null if we never got a response |
last_error | Present on a failed or expired attempt |
delivered_at | On a successful delivery |
last_attempt_at | On a failed or expired delivery |
This endpoint is the answer to "did you send me X". Check it before opening a support ticket. Be aware that the only documented filter is status, so answering "did you send me anything for product 12345" means paging the history. Ask your JOOR contact whether a product_id filter exists or is planned.
Not yet contractual. The shapes for rotate, test and resume are as documented, but treat them as provisional; report anything that deviates.
Errors, subscription endpoints
| Code | When |
|---|---|
400 | Unknown field in the body, callback_url not HTTPS or pointing at a private, loopback or link local address, callback_url with embedded credentials, events empty or containing an unknown event, PATCH with none of the three editable fields, limit out of range, status filter outside the allowed set |
401 | Missing, invalid or expired token |
403 | The account is not a retailer account, or the subscription belongs to another account |
404 | Unknown subscription, or one belonging to another account |
405 | Wrong verb for the route |
409 | A live subscription already uses this callback_url |
429 | Out of credits. Remember that POST, PATCH and DELETE cost 5 credits each |
The per account cap of 5 live subscriptions is documented, but the status code and error returned when you exceed it is not. Ask your JOOR contact, or find out by testing against your own account.
