Skip to main content
A webhook endpoint is a URL SouthPay 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

signing_secret appears only in the create response and in the response to rotate_secret. Every other read returns signing_secret_preview, the first twelve characters. Store the full value before you close the terminal.
Requires the 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.
For payment and transaction events, data.object has exactly seven keys: id, status, settlement_amount_cents, settlement_currency, from_status, to_status, metadata.id holds the payment intent’s reference, not its UUID. There is no reference key. There is no settlement_amount key. The amount is an integer in minor units under settlement_amount_cents.
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

  1. Split Southpay-Signature on , into t and v1.
  2. Build the signed string "{t}.{raw request body}".
  3. Compute HMAC-SHA256 over it, keyed with the signing secret, and hex-encode it.
  4. Compare to v1 with a timing-safe comparison.
Two details break most first attempts.Use the raw body bytes. Parsing JSON and re-serializing it changes whitespace and key order, and the digest will not match.Use the secret exactly as issued, whsec_ prefix included. It is the literal UTF-8 string that keys the HMAC. Do not strip the prefix and do not base64-decode it.
SouthPay does not enforce a timestamp tolerance on outbound deliveries. It only stamps 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 pass subscribed_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.
Five event names pass validation but are never emitted. Nothing in the API creates a delivery for any of them. If you subscribe, you will wait forever and no error will tell you so.
  • payout.created
  • payout.confirmed
  • payout.failed
  • fee_sweep.confirmed
  • fee_sweep.failed
Payout lifecycle is not published over webhooks today. Poll GET /api/v2/payouts or GET /api/v2/payouts/{id} and read status and state_transitions. If you already have a subscription to any of these names, it is safe to leave in place and safe to remove. It has never delivered anything either way.

Events you cannot receive

The reverse problem exists too. Four event types are produced internally but cannot appear in subscribed_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

Every non-2xx response is retried, including 400, 401, 403 and 404. There is no “client errors are not retried” rule. A handler that returns 400 on a payload it does not recognise will be hammered ten times over eighteen hours for every such event.Return 2xx for events you choose to ignore. Reserve non-2xx for “I could not process this and I want it again”.
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.
Deliveries are also refused before they leave if the URL resolves to a private, loopback, link-local or unique-local address. The delivery is marked 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.
Claim the id before doing the work, not after, or two concurrent retries can both pass the check.

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-level account 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.
Last modified on August 31, 2026