Payments API
Service:
hmp-payment-service(port 3014) — all endpoints exposed viahmp-api-gatewayathttps://api.<env>.keynovahotels.com/api/*. Architecture: Modular Payments Platform Tenant onboarding: Configure Razorpay
This is the HTTP surface of the modular payments platform. Consumer services
call these endpoints via PaymentServiceClient from @hmp/service-clients;
operator UIs call them via the dashboard API layer. Raw HTTP is documented
here for integration testing and support tooling.
Authentication
All endpoints (except webhooks — see §6) require a JWT Bearer token.
| Caller | Token |
|---|---|
| End-user (dashboard, admin portal) | User JWT from hmp-auth-service login |
| Backend service (booking engine, frontdesk, etc.) | INTERNAL_SERVICE_TOKEN service-account JWT (see internal-service-auth) |
Every request must also supply:
| Header | Required | Meaning |
|---|---|---|
Authorization | yes | Bearer <jwt> |
x-tenant-id | service-account only | UUID of the tenant the request acts on |
x-property-id | when applicable | UUID of the property for per-property scoped calls |
x-request-id | recommended | Correlation ID echoed in logs + events |
Idempotency-Key | on POST /payments/initiate, POST /payments/refund | Arbitrary string, unique per logical intent, retained 90 days |
Error model
All error responses follow the platform shape:
{
"statusCode": 422,
"error": "PROVIDER_DECLINED",
"message": "Card was declined by the issuer",
"details": {
"providerCode": "razorpay-gateway",
"providerErrorCode": "BAD_REQUEST_ERROR",
"attemptId": "7a4b1e9c-..."
},
"requestId": "req_01HZXX..."
}
| Status | When |
|---|---|
400 | Malformed body, missing required field, bad UUID |
401 | Missing / invalid JWT, webhook signature mismatch |
403 | Tenant mismatch (e.g. propertyId belongs to another tenant) |
404 | Unknown paymentId, merchantAccountId, etc. |
409 | Idempotency conflict (same key, different body); mode is immutable after first success |
422 | Business-rule failure — PROVIDER_DECLINED, DEVICE_OFFLINE, MERCHANT_ACCOUNT_INACTIVE |
429 | Rate limit exceeded (gateway middleware or per-tenant cap) |
500 | Unexpected server error; correlation ID in response body |
503 | Provider unreachable after retries; Retry-After header set |
504 | Provider timeout — reconciler will resolve the attempt in ≤ 5 min |
1. Initiate a payment
Creates a Payment + first PaymentAttempt against the tenant's configured
Razorpay MerchantAccount. Returns the client-side checkout envelope.
POST /api/payments/initiate
Request
{
"useCase": "GUEST_BOOKING_DEPOSIT",
"externalReferenceType": "RESERVATION",
"externalReferenceId": "b9f4...-res-uuid",
"amount": { "value": 250000, "currency": "INR" },
"propertyId": "40b29005-5196-4ba7-a71d-94323caee4dc",
"merchantAccountId": "optional — omit to use tenant default for propertyId",
"guestContext": {
"name": "Asha Ramanathan",
"email": "asha@example.com",
"phone": "+919876543210"
},
"returnUrl": "https://book.acme-hotel.com/confirmation",
"notes": { "source": "booking-engine" }
}
Notes:
amount.valueis in the currency's smallest unit — paise for INR.useCaseis one ofGUEST_BOOKING_DEPOSIT,GUEST_FOLIO_SETTLEMENT,POS_ORDER_PAYMENT,BANQUET_EVENT_PAYMENT,SPA_SERVICE_PAYMENT,SUBSCRIPTION,PAYMENT_LINK_ADHOC,OTHER.externalReferenceTypeis one ofRESERVATION,FOLIO,POS_ORDER,BANQUET_EVENT,SPA_BOOKING,TENANT_INVOICE,PAYMENT_LINK,NONE.
Response 201 Created
{
"paymentId": "a1b2c3d4-....",
"status": "PENDING",
"attempt": {
"attemptId": "7a4b1e9c-....",
"status": "PENDING_USER_ACTION"
},
"provider": {
"code": "razorpay-gateway",
"mode": "LIVE",
"checkout": {
"kind": "HOSTED",
"razorpayKeyId": "rzp_live_XXXXXXXXXXXX",
"orderId": "order_MNopQR1234",
"amount": 250000,
"currency": "INR"
}
}
}
curl
curl -X POST https://api.keynovahotels.com/api/payments/initiate \
-H "Authorization: Bearer $JWT" \
-H "x-tenant-id: 00000000-0000-4000-a000-000000000001" \
-H "Idempotency-Key: booking-engine:RESERVATION:b9f4-res:n7Zp" \
-H "Content-Type: application/json" \
-d '{
"useCase": "GUEST_BOOKING_DEPOSIT",
"externalReferenceType": "RESERVATION",
"externalReferenceId": "b9f4...-res-uuid",
"amount": { "value": 250000, "currency": "INR" },
"propertyId": "40b29005-5196-4ba7-a71d-94323caee4dc"
}'
Error codes
| Status | Error | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing field, bad amount, unknown useCase |
| 403 | TENANT_MISMATCH | propertyId doesn't belong to JWT tenant |
| 404 | MERCHANT_ACCOUNT_NOT_FOUND | No active account for (tenantId, propertyId, provider) |
| 409 | IDEMPOTENCY_CONFLICT | Key already used with different body |
| 422 | MERCHANT_ACCOUNT_INACTIVE | Account deactivated (typically after repeated auth errors) |
| 503 | PROVIDER_UNREACHABLE | Network failure to provider after 3 retries |
2. Fetch payment details
GET /api/payments/:paymentId
Returns the Payment plus every PaymentAttempt (multiple attempts per
payment when the guest retries after a decline).
Response 200 OK
{
"paymentId": "a1b2c3d4-....",
"tenantId": "00000000-0000-4000-a000-000000000001",
"propertyId": "40b29005-5196-4ba7-a71d-94323caee4dc",
"useCase": "GUEST_BOOKING_DEPOSIT",
"externalReferenceType": "RESERVATION",
"externalReferenceId": "b9f4...-res-uuid",
"amount": { "value": 250000, "currency": "INR" },
"status": "SUCCEEDED",
"successfulAttemptId": "8b5c2f0d-....",
"refundedAmount": { "value": 0, "currency": "INR" },
"createdAt": "2026-04-14T09:31:17.802Z",
"succeededAt": "2026-04-14T09:32:48.118Z",
"attempts": [
{
"attemptId": "7a4b1e9c-....",
"status": "FAILED",
"providerCode": "razorpay-gateway",
"providerReference": "pay_MNopQR1234",
"paymentMethod": "CARD",
"cardLast4": "4242",
"failureReason": "card declined",
"createdAt": "2026-04-14T09:31:48.501Z"
},
{
"attemptId": "8b5c2f0d-....",
"status": "CAPTURED",
"providerCode": "razorpay-gateway",
"providerReference": "pay_MNopSTUVWX",
"paymentMethod": "UPI",
"upiVpa": "a***a@okaxis",
"createdAt": "2026-04-14T09:32:39.220Z",
"capturedAt": "2026-04-14T09:32:48.080Z"
}
]
}
curl
curl https://api.keynovahotels.com/api/payments/a1b2c3d4-... \
-H "Authorization: Bearer $JWT" \
-H "x-tenant-id: 00000000-0000-4000-a000-000000000001"
Error codes
| Status | Error |
|---|---|
| 403 | TENANT_MISMATCH — payment belongs to a different tenant |
| 404 | PAYMENT_NOT_FOUND |
3. Refund a payment
Creates a Refund against the successful attempt of a Payment.
POST /api/payments/:paymentId/refund
Request
{
"amount": { "value": 250000, "currency": "INR" },
"reason": "Guest cancellation — within free-cancel window",
"initiatedBy": "user_uuid_of_staff_member",
"notes": { "cancellationTicket": "T-8812" }
}
- Omit
amountfor a full refund; supply it for a partial refund. Partial refunds require the provider to declarePARTIAL_REFUNDcapability. - Multiple partial refunds are allowed up to the successful attempt's amount.
Response 201 Created
{
"refundId": "c9d8e7f6-....",
"paymentId": "a1b2c3d4-....",
"paymentAttemptId": "8b5c2f0d-....",
"amount": { "value": 250000, "currency": "INR" },
"status": "PENDING",
"providerRefundId": "rfnd_MNopQR1234",
"createdAt": "2026-04-14T10:00:00.000Z"
}
Refund status transitions to COMPLETED when the provider's
refund.processed webhook arrives. Subscribers receive refund.processed on
Redis Streams.
curl
curl -X POST https://api.keynovahotels.com/api/payments/a1b2c3d4-.../refund \
-H "Authorization: Bearer $JWT" \
-H "x-tenant-id: 00000000-0000-4000-a000-000000000001" \
-H "Idempotency-Key: dashboard:refund:a1b2c3d4:fullcancel" \
-H "Content-Type: application/json" \
-d '{ "reason": "Guest cancellation" }'
Error codes
| Status | Error |
|---|---|
| 404 | PAYMENT_NOT_FOUND |
| 409 | PAYMENT_NOT_REFUNDABLE — payment not SUCCEEDED, or already fully refunded |
| 422 | REFUND_AMOUNT_EXCEEDS_REMAINING |
| 422 | PROVIDER_CAPABILITY_MISSING — provider doesn't support partial refunds |
4. Merchant account management
Operator endpoints for CRUD over stored provider credentials. Invoked from the dashboard's Settings → Payment Providers screen. See Tenant Onboarding Guide for the UX walkthrough.
4.1 Create a merchant account
POST /api/merchant-accounts
Request
{
"providerCode": "razorpay-gateway",
"propertyId": null,
"mode": "LIVE",
"displayName": "Razorpay — Main",
"credentials": {
"key_id": "rzp_live_XXXXXXXXXXXX",
"key_secret": "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
},
"webhookSecret": "optional — can be added after Razorpay generates it"
}
propertyId: nullmakes the account the tenant-wide default. Supply a UUID to scope it to one property (franchise / multi-entity chains).modemust match the key prefix —rzp_live_*→LIVE,rzp_test_*→SANDBOX. Cross-prefix attempts return 422.
Response 201 Created
{
"merchantAccountId": "m0a1b2c3-....",
"providerCode": "razorpay-gateway",
"mode": "LIVE",
"displayName": "Razorpay — Main",
"maskedCredentials": { "key_id": "rzp_live_XX••••••••XX" },
"webhookUrl": "https://api.keynovahotels.com/api/webhooks/razorpay-gateway/m0a1b2c3-....",
"isActive": false,
"createdAt": "2026-04-14T11:00:00.000Z"
}
isActive flips to true once webhookSecret is supplied and
validateCredentials() succeeds. The response includes the webhookUrl that
must be pasted into the Razorpay dashboard.
curl
curl -X POST https://api.keynovahotels.com/api/merchant-accounts \
-H "Authorization: Bearer $JWT" \
-H "x-tenant-id: 00000000-0000-4000-a000-000000000001" \
-H "Content-Type: application/json" \
-d '{
"providerCode": "razorpay-gateway",
"mode": "LIVE",
"displayName": "Razorpay — Main",
"credentials": { "key_id": "rzp_live_...", "key_secret": "..." }
}'
4.2 List merchant accounts
GET /api/merchant-accounts
GET /api/merchant-accounts?propertyId=<uuid>
GET /api/merchant-accounts?providerCode=razorpay-gateway&isActive=true
Response 200 OK
{
"data": [
{
"merchantAccountId": "m0a1b2c3-....",
"providerCode": "razorpay-gateway",
"mode": "LIVE",
"displayName": "Razorpay — Main",
"propertyId": null,
"maskedCredentials": { "key_id": "rzp_live_XX••••••••XX" },
"isActive": true,
"lastSuccessfulPaymentAt": "2026-04-14T09:32:48.118Z"
}
]
}
4.3 Rotate credentials
PATCH /api/merchant-accounts/:id/credentials
Request
{
"credentials": {
"key_id": "rzp_live_NEWKEYID",
"key_secret": "NEWSECRETNEWSECRETNEWSECRETNEWSEC"
},
"webhookSecret": "optional — supply if rotating the webhook secret too"
}
Atomic: validate new keys against the provider → re-encrypt under current KMS
DEK → UPDATE row → emit AuditLog entry merchant_account.rotated.
Response 200 OK — same shape as 4.1. Returns 422 if the new keys fail
validateCredentials().
Once a MerchantAccount has processed its first successful payment, mode
cannot change. To migrate SANDBOX → LIVE, create a new LIVE account and
deactivate the sandbox one. This prevents historical reinterpretation.
4.4 Activate / deactivate
PATCH /api/merchant-accounts/:id/activate
PATCH /api/merchant-accounts/:id/deactivate
No request body. Returns 200 with the updated record. Deactivating an
account makes subsequent POST /payments/initiate calls that resolve to it
return 422 MERCHANT_ACCOUNT_INACTIVE. Existing in-flight payments complete
normally; webhooks for them are still accepted.
Error codes (all 4.x endpoints)
| Status | Error |
|---|---|
| 400 | VALIDATION_ERROR — mode / key prefix mismatch, missing field |
| 403 | TENANT_MISMATCH |
| 404 | MERCHANT_ACCOUNT_NOT_FOUND |
| 409 | DUPLICATE_ACTIVE_ACCOUNT — another active account exists for (tenantId, propertyId, providerCode) |
| 422 | CREDENTIAL_VALIDATION_FAILED — provider rejected the keys |
5. Provider catalog
GET /api/provider-catalog
Static seed of every provider KeyNova could support, with declared capabilities. Dashboard consumes this to populate the "Add Account" dropdown.
Response 200 OK
{
"data": [
{
"code": "razorpay-gateway",
"displayName": "Razorpay",
"family": "GATEWAY",
"enabled": true,
"capabilities": [
"HOSTED_CHECKOUT", "API_PAYMENT", "PAYMENT_LINK",
"QR_UPI", "REFUND", "PARTIAL_REFUND"
],
"supportedMethods": ["CARD", "UPI", "NETBANKING", "WALLET", "EMI"],
"regions": ["IN"]
},
{
"code": "razorpay-terminal",
"displayName": "Razorpay POS",
"family": "TERMINAL",
"enabled": true,
"capabilities": ["TERMINAL_DEVICE", "REFUND"],
"supportedMethods": ["CARD", "UPI"],
"regions": ["IN"]
}
]
}
enabled: false entries are hidden from tenant UI but visible in the admin
portal for staged rollout.
6. Webhook receiver
POST /api/webhooks/:providerCode/:merchantAccountId
Public endpoint. No JWT. The only authentication is the provider's
signature, verified against the MerchantAccount's stored webhookSecret.
| Path param | Example | Meaning |
|---|---|---|
providerCode | razorpay-gateway | Matches ProviderCatalog.code |
merchantAccountId | m0a1b2c3-... | UUID — opaque; never reveals tenantId |
Headers
| Header | Required | Provider |
|---|---|---|
X-Razorpay-Signature | yes (Razorpay) | HMAC-SHA256 of raw body using webhook secret |
X-Razorpay-Event-Id | yes (Razorpay) | Used as providerEventId for dedup |
Body: raw provider payload (preserved byte-exact by the gateway; do not re-encode JSON en route).
Handling sequence
- Insert
WebhookEventrow withprocessingStatus = RECEIVED,signatureVerified = false. - Dedup on
(providerType, providerEventId)— duplicates return200 OKwith no side effects. - Lookup
MerchantAccountby path param; load decryptedwebhookSecret. - Verify signature. Mismatch →
401,WebhookEvent.processingStatus = FAILED. adapter.verifyAndParseWebhook()canonicalises toProviderWebhookEvent.- Dispatch:
payment.captured→ AttemptCAPTURED+ PaymentSUCCEEDED;payment.failed→ AttemptFAILED;refund.processed→ RefundCOMPLETED. - Emit domain event on Redis Streams (
payment.succeeded, etc.). WebhookEvent.processingStatus = PROCESSED, return200 OK.
Response
200 OK — processed (including dedup hits)
400 Bad Request — malformed payload
401 Unauthorized — signature verification failed
404 Not Found — unknown providerCode or merchantAccountId
Provider re-delivery on 5xx is tolerated end-to-end by the dedup key.
Webhooks older than 10 minutes (per provider timestamp) are rejected with
401. Failed webhook signatures that exceed 1% for 5 minutes fire the
webhook-signature-failures alert (runbook at
hmp-infrastructure/monitoring/runbooks/webhook-signature-failures.md).
Rate limits
| Scope | Limit |
|---|---|
| Gateway per-IP (all endpoints) | 600 req / 5 min |
POST /payments/initiate per tenant | 50 / min (tunable in tenant config) |
POST /payment-links/:slug/attempt per link | 20 / lifetime (card-testing defence) |
| Guest-facing link landing per IP | 10 / min |
Exceeding a limit returns 429 with a Retry-After header (seconds). There
is no ThrottlerGuard registered as APP_GUARD — rate limits live in the
gateway's RateLimitMiddleware (see project CLAUDE.md).
Events published
All consumers react to domain events on Redis Streams via @hmp/messaging:
| Topic | Emitted when | Payload |
|---|---|---|
payment.initiated | Payment created | { paymentId, tenantId, propertyId, useCase, externalReferenceType, externalReferenceId, amount } |
payment.succeeded | Attempt captured → Payment success | { paymentId, attemptId, amount, providerCode, externalReferenceType, externalReferenceId } |
payment.failed | Attempt final failure (no retry pending) | { paymentId, attemptId, failureReason } |
refund.processed | Refund completed | { refundId, paymentId, amount } |
link.created / link.paid / link.expired | PaymentLink lifecycle | { linkId, paymentId, slug, ... } |
terminal.paired / terminal.offline | Device lifecycle | { deviceId, tenantId, propertyId, status } |
Consumer subscription pattern: see
analytics-event-flow and the
SKIP_EVENT_SUBSCRIPTIONS flag convention.
See also
- Modular Payments Architecture
- Tenant Onboarding Guide — Razorpay
- Internal Service-to-Service Auth
- Design spec (not in site build):
hmp-docs/docs/superpowers/specs/2026-04-14-modular-payments-platform-design.md