Appearance
Payment Application Developer Guide
Implement one adapter between POS Hub's normalized payment contract and your provider. Keep the provider's credentials and payment-method data inside that adapter; POS Hub should receive only hosted links, references, normalized state, and safe diagnostics.
Verify every POS Hub callback
POS Hub sends each provider callback as POST application/json with a hexadecimal SHA-1 HMAC in X-Webhook-Signature. Hash the exact raw body with the application secret and use a timing-safe comparison before parsing JSON.
javascript
import { createHmac, timingSafeEqual } from 'node:crypto'
export function isValidPosHubRequest(rawBody, signature, secret) {
if (!signature) return false
const expected = createHmac('sha1', secret).update(rawBody).digest('hex')
const actualBuffer = Buffer.from(signature, 'utf8')
const expectedBuffer = Buffer.from(expected, 'utf8')
return (
actualBuffer.length === expectedBuffer.length &&
timingSafeEqual(actualBuffer, expectedBuffer)
)
}Reject an invalid signature. Do not parse and re-stringify the body before calculating the HMAC.
Implement the provider endpoints
Create payment
POS Hub calls createPaymentUrl with:
json
{
"payment": {
"id": "7d6917ea-e71f-4dc4-b820-4cc18298ac27",
"accountId": "9e7f3943-bcb7-43dc-8474-d2fa81d409dd",
"accountName": "Example Restaurant Group",
"locationId": "d25c644c-45d2-4f1e-b49e-60f8c62d67c5",
"locationName": "Example Central Kitchen",
"resellerId": "d9ed4ec1-5664-45ea-bf45-d7701f933407",
"orderId": "20aa1fa3-83ff-4517-b5b9-2f53839885e2",
"amount": 2599,
"currency": "GBP",
"externalOrderReference": "ORDER-1001",
"customer": {
"id": "CUSTOMER-7",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"phone": "+447700900000"
},
"status": "INITIALIZING"
}
}The real payment object also carries POS Hub audit, provider-selection, description, and metadata fields when present. It excludes the creator's returnUrl. Currency defaults to the location currency when omitted from the create request. An optional orderId is accepted only when that POS Hub order belongs to the payment location. Use payment.id as the provider idempotency key; do not assume the external order reference is globally unique.
Return HTTP 2xx only after the provider has created the payment:
json
{
"status": "PENDING",
"paymentLink": "https://pay.example.com/7d6917ea-e71f-4dc4-b820-4cc18298ac27",
"paymentLinkExpiresAt": "2026-08-03T15:30:00.000Z",
"externalProviderPaymentId": "psp_payment_123",
"externalProviderStatus": "AWAITING_CUSTOMER"
}status must be PENDING and paymentLink must be HTTPS. If you can prove the provider did not create anything, return a non-success response that explicitly permits safe failover:
json
{
"error": {
"code": "MERCHANT_NOT_ENABLED",
"message": "The merchant is not enabled with this provider",
"failoverSafe": true
}
}Never set failoverSafe when a timeout or provider error leaves creation uncertain.
Get payment
POS Hub calls getPaymentUrl with { "payment": PaymentEntity }. Fetch the provider payment using externalProviderPaymentId or your mapping from payment.id, then return one or more provider-owned patch fields:
json
{
"status": "SUCCEEDED",
"externalProviderPaymentId": "psp_payment_123",
"externalProviderStatus": "CAPTURED"
}Allowed fields are status, externalProviderPaymentId, externalProviderStatus, paymentLinkExpiresAt, and failure. The response must contain at least one field. Do not return POS Hub-owned fields such as amount, refunds, updatedBy, or version.
Cancel payment
POS Hub calls cancelPaymentUrl after reconciliation:
json
{
"payment": { "id": "7d6917ea-e71f-4dc4-b820-4cc18298ac27" },
"cancellation": { "reason": "Customer requested cancellation" }
}The actual payment value is the payment entity, excluding returnUrl. Make cancellation idempotent by payment.id. A confirmed provider-side cancellation response is:
json
{
"status": "CANCELLED",
"externalProviderStatus": "VOIDED"
}If the provider has no cancellation operation but the product deliberately supports a POS Hub-only cancellation, return a successful normalized cancellation while making the limitation explicit:
json
{
"status": "CANCELLED",
"externalProviderStatus": "CANCELLATION_NOT_SUPPORTED"
}This transition is terminal in POS Hub. It does not cancel or expire the provider's hosted payment link, so document the operational risk and handle any later provider collection out of band.
If a POS Hub-only cancellation is not appropriate, return:
json
{
"error": {
"code": "CANCELLATION_NOT_SUPPORTED",
"message": "This provider payment cannot be cancelled"
}
}Do not return success while a supported provider-side cancellation is still pending. An invalid or uncertain result is recorded as UNKNOWN.
Refund payment
POS Hub calls refundPaymentUrl with the payment and a refund allocated by POS Hub:
json
{
"payment": { "id": "7d6917ea-e71f-4dc4-b820-4cc18298ac27" },
"refund": {
"id": "53e7f923-20c6-45e8-a680-7b2cb2c5222a",
"amount": 1000,
"status": "PENDING",
"reason": "Item unavailable",
"createdAt": "2026-08-03T14:00:00.000Z",
"updatedAt": "2026-08-03T14:00:00.000Z"
}
}Use refund.id as the refund idempotency key. Return HTTP 2xx with SUCCEEDED, FAILED, or UNKNOWN:
json
{
"status": "SUCCEEDED",
"externalProviderRefundId": "psp_refund_456",
"externalProviderStatus": "REFUNDED"
}For a known failure, return FAILED and a safe diagnostic:
json
{
"status": "FAILED",
"failure": {
"code": "REFUND_REJECTED",
"message": "The provider rejected the refund"
}
}Return UNKNOWN when you cannot prove success or failure. POS Hub derives PARTIALLY_REFUNDED, REFUNDED, and refundedAmount; the payment application must not set them directly.
Push provider status changes
When the provider sends your application an asynchronous status event, update POS Hub with the selected application's OAuth2 token:
bash
curl --request PATCH \
'https://api-sit-dr.stage.tryposhub.com/v1/accounts/{accountId}/locations/{locationId}/payments/{paymentId}' \
--header 'Authorization: Bearer {accessToken}' \
--header 'Content-Type: application/json' \
--data '{
"status": "SUCCEEDED",
"externalProviderStatus": "CAPTURED",
"externalProviderPaymentId": "psp_payment_123"
}'Only the selected, connected payment application can call this PATCH, and it requires payments.write. Do not use a user-scoped token. POS Hub validates the transition and records the application in updatedBy and statusHistory.
Map provider events to these values: PENDING, PROCESSING, SUCCEEDED, FAILED, CANCELLED, or UNKNOWN. Include failure.code and failure.message for a known failure. Process duplicate provider events idempotently and treat HTTP 409 PAYMENT_CONFLICT as a signal to fetch the latest payment before deciding whether another update is required.
POS Hub payment endpoints
Creator applications use the account and location routes. Reseller routes provide equivalent operational access under a reseller user context, except that provider PATCH is intentionally available only on the account route.
| Method | Account route | Scope | Application access |
|---|---|---|---|
GET | /v1/accounts/{accountId}/payments | payments.read | Lists payments across all account locations; applications see only payments they created. |
POST | /v1/accounts/{accountId}/payments | payments.write | Creates a hosted payment for the locationId supplied in the body after verifying it belongs to the account. |
GET | /v1/accounts/{accountId}/payments/{paymentId} | payments.read | Retrieves an account payment; creator or selected provider applications may access it. |
POST | /v1/accounts/{accountId}/payments/{paymentId}/cancel | payments.cancel | Cancels an account payment; application callers must be the creator. |
POST | /v1/accounts/{accountId}/payments/{paymentId}/refund | payments.refund | Creates a full or partial refund; application callers must be the creator. |
GET | /v1/accounts/{accountId}/locations/{locationId}/payments | payments.read | Lists payments created by the calling application. |
POST | /v1/accounts/{accountId}/locations/{locationId}/payments | payments.write | Creates a hosted payment. |
GET | /v1/accounts/{accountId}/locations/{locationId}/payments/{paymentId} | payments.read | Creator or selected provider. |
PATCH | /v1/accounts/{accountId}/locations/{locationId}/payments/{paymentId} | payments.write | Selected provider only. |
POST | /v1/accounts/{accountId}/locations/{locationId}/payments/{paymentId}/cancel | payments.cancel | Creator only. |
POST | /v1/accounts/{accountId}/locations/{locationId}/payments/{paymentId}/refunds | payments.refund | Creator only. |
Operations users can list every payment owned by a reseller across all of its accounts and locations with GET /v1/resellers/{resellerId}/payments and the payments.read scope. They can create a payment with POST /v1/resellers/{resellerId}/payments, the payments.write scope, and both accountId and locationId in the request body. POS Hub verifies that the account belongs to the reseller and that the location belongs to the account before creating the payment.
Reseller operations users can retrieve a payment with GET /v1/resellers/{resellerId}/payments/{paymentId}, cancel it with POST /v1/resellers/{resellerId}/payments/{paymentId}/cancel, or refund it with POST /v1/resellers/{resellerId}/payments/{paymentId}/refund. These routes use the payments.read, payments.cancel, and payments.refund scopes respectively and verify the payment's account and location ownership before calling the payment service.
List requests support status, externalOrderReference, externalPaymentReference, limit, and nextPageKey. Item reads normally return stored state; add ?consistentRead=true when you need POS Hub to call the selected provider and reconcile first.
All errors use one stable envelope:
json
{
"error": {
"code": "PAYMENT_INVALID_STATE",
"message": "Payment cannot be refunded from PENDING"
}
}Handle 400, 401, 403, 404, 409, 422, 502, and 504 by code as well as HTTP status. A 502 or 504 can represent an ambiguous provider outcome; fetch and reconcile the payment instead of blindly repeating a create, cancellation, or refund.
Payment lifecycle webhooks
Creator applications can enable PAYMENT_INSERT and PAYMENT_MODIFY on their webhook endpoint. Events contain identifiers, external references, previous/current status, refunded amount, timestamps, and a resource link. They intentionally omit customer details, metadata, payment links, and return URLs.
Payment lifecycle events are status-oriented: same-status provider metadata changes do not produce a PAYMENT_MODIFY event. The application recorded in updatedBy does not receive its own update back. Consumers should deduplicate events by eventId and fetch resourceHref when they need the current full payment.
Development checklist
Before handing over a payment application in SIT, verify:
- All four endpoints reject an invalid HMAC and accept the exact signed raw body.
- Create and refund use
payment.idandrefund.idas idempotency keys. - Create returns a valid HTTPS link within 10 seconds.
- Only proven pre-creation failures set
failoverSafe: true. - Provider states map to valid forward POS Hub transitions.
- Timeout and uncertain outcomes remain
UNKNOWNand are reconciled. - Cancellation and refund callbacks are idempotent and return final normalized results.
- Provider webhooks drive the POS Hub
PATCHendpoint with application credentials. - Logs and errors contain no payment-method data, credentials, or unnecessary customer data.
- Creator webhook handling deduplicates events and does not expect an echo to
updatedBy.
Use the environment's interactive API specification at {baseUrl}/docs/index.html as the schema reference. See Environments, Authentication, Error Handling, and Webhooks for the shared platform behaviour.
