POSTs signed JSON to when something changes on your store. You register the URL, name every event you want, store the signing secret, and verify each delivery before acting on it.
Register an endpoint
webhooks:write scope. The endpoint id is a bare UUID. There is no we_ prefix scheme anywhere in this API.
subscribed_events is required, and literal
subscribed_events must be a non-empty array of exact event names from the catalogue. It is validated as a strict subset, and matching at dispatch time is literal array containment.
Wildcards are not supported. "*" fails validation. So does "payment_intent.*". A merchant who wants everything must enumerate all 23 names. Omitting the field entirely yields an empty array, which then fails the presence check with 422 and a {"errors": [...]} body.
If you create a second endpoint with the same
platform value (shopify, woocommerce, prestashop, magento) in the same mode, SouthPay does not return a conflict. It adopts the existing row, rotates its secret, and returns 200 with the new signing_secret. The old secret stops working immediately. This is how plugin reinstalls are meant to work; it is surprising if you hit it by accident.The envelope
There is no top-level
"object": "event" key.
Billing events are different. invoice.* and subscription.* serialize the full detail view of the invoice or subscription into data.object, so you get line items, customer, period boundaries and every other field rather than the seven-key summary. store.verification.* events use a freshly generated UUID as the envelope id and carry the store’s verification state.
api_version is stamped from a fixed value, not from the api_version field on your endpoint. Changing that field has no effect on what you receive. Treat the field as informational.Headers
Verifying the signature
- Split
Southpay-Signatureon,intotandv1. - Build the signed string
"{t}.{raw request body}". - Compute
HMAC-SHA256over it, keyed with the signing secret, and hex-encode it. - Compare to
v1with a timing-safe comparison.
t. If you want replay protection, reject deliveries whose t is more than a few minutes old. That is your decision to make, not something the API does for you.
Event catalogue
Twenty-three names passsubscribed_events validation. Eighteen of them are emitted.
Payment intent events
Read
data.object.status and, where you need the reason, fetch the intent for its state_reason. See Payment lifecycle.
Transaction events
Billing events
Emitted only for stores using invoicing and subscriptions.data.object is the full resource, not the seven-key summary.
Verification events
Most useful to platforms tracking sub-merchant onboarding.
Events that never arrive
The catalogue above lists eighteen events. Five more pass validation and do nothing.Events you cannot receive
The reverse problem exists too. Four event types are produced internally but cannot appear insubscribed_events, so no endpoint ever matches them and they are dropped:
payment_intent.canceled, payment_intent.underpaid_detected, transaction.pending, transaction.submitted.
The one that catches people out is payment_intent.canceled, emitted when an agent cancels a pending intent. Watch for payment_intent.expired and check for state_reason: "canceled_by_agent" on the intent instead.
Delivery and retries
Retries back off on this schedule: 30 seconds, 2 minutes, 5 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 5 hours, 10 hours. After the tenth failed attempt the endpoint flips to
status: "error" and the store owner is emailed.
While an endpoint is not
active, whether disabled by you or flipped to error, queued deliveries are dropped rather than held. Re-enabling the endpoint does not replay them. Use redeliver for anything you need back.failed and the endpoint flips to error. Tunnel to a public hostname when testing locally.
Deduplication
Delivery is at-least-once. SouthPay keys each delivery internally on(endpoint, event), which prevents most duplicates, but a retry can still repeat a delivery your handler already processed and acknowledged slowly. Guard on the envelope id.
Managing an endpoint
send_test takes an optional event_type. It defaults to your first subscribed event, falling back to payment_intent.completed. An unknown name returns 422 "Unknown event type".
redeliver takes delivery_id, in the query string or the JSON body. Delivery ids come from recent_deliveries on the endpoint read.
Rotating the secret
POST /api/v2/webhook_endpoints/{id}/rotate_secret returns the endpoint with a new signing_secret.
Rotation is an immediate hard cutover. There is no overlap window and no dual-signing period. The moment the request returns, every delivery is signed with the new secret and the old one verifies nothing, including deliveries already queued and retries of older events.Deploy so that the new secret is live before you rotate: read the secret from a store your running processes re-read, rotate, then confirm. Rotating first and deploying second guarantees a gap of failed verifications, and those failures are retried as non-
2xx for eighteen hours.Endpoint fields
Delivery
status values are pending, attempting, delivered, failed, ignored.
Platform fan-out
If you are a platform, payment, transaction and store-verification events for your sub-merchants are delivered to your endpoints as well as theirs, with a top-levelaccount field naming the sub-merchant.
Billing events do not fan out. An invoice or subscription event on a sub-merchant reaches that merchant’s endpoints only.
