Managing Subscriptions

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

FieldTypeNotes
idintegerPath segment for every other subscription call
retailer_account_idintegerThe account this subscription belongs to. Matches retailer_account_id in the payload
callback_urlstringHTTPS and public domains
eventsarray of stringSubset of the three event types. Deduplicated, order preserved. Replaced outright by PATCH, never merged
activebooleanWhat you set through PATCH
statusstringactive and suspended are documented here. Whether other values exist is not stated; ask your JOOR contact for the full enum
created, modifiedtimestamp
suspended_at, suspend_reasontimestamp, stringPresent only while suspended
statsobjectReturned by "Retrieve" only
secret_keystringReturned by create and rotate only, exactly once

Validation

RuleDetail
callback_urlRequired. 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
eventsRequired. Non empty subset of product.created, product.updated, product.deleted. An empty list is rejected
Unknown fieldsRejected with 400. Do not send anything not listed here
Per account cap5 live subscriptions
DuplicatesAn 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 fieldMeaning
sent_24hDeliveries sent in the last 24 hours
failed_24hFailed attempts in the last 24 hours
expired_24hDeliveries that exhausted all four attempts in the last 24 hours. This is the number that means data loss, so alert on it
consecutive_failuresCurrent run. 50 suspends
last_success_atTimestamp 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": [] }
modeMeaning
catch_upThe suspended window is being replayed to you, paced
full_resync_advisedToo 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"
  }
}
FieldNotes
delivery_idMatches the X-JOOR-Delivery-Id header and the delivery_id in the body
product_idThe product the notification concerned
eventOne of the three event types
changesThe sections that were embedded. Absent for a product.deleted
statuspending, sent, failed, expired
attempts1 to 4. At 4 with status: "expired" the notification was dropped
http_statusWhat your endpoint answered, or null if we never got a response
last_errorPresent on a failed or expired attempt
delivered_atOn a successful delivery
last_attempt_atOn 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

CodeWhen
400Unknown 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
401Missing, invalid or expired token
403The account is not a retailer account, or the subscription belongs to another account
404Unknown subscription, or one belonging to another account
405Wrong verb for the route
409A live subscription already uses this callback_url
429Out 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.