Skip to main content

Payments API

Service: hmp-payment-service (port 3014) — all endpoints exposed via hmp-api-gateway at https://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.

CallerToken
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:

HeaderRequiredMeaning
AuthorizationyesBearer <jwt>
x-tenant-idservice-account onlyUUID of the tenant the request acts on
x-property-idwhen applicableUUID of the property for per-property scoped calls
x-request-idrecommendedCorrelation ID echoed in logs + events
Idempotency-Keyon POST /payments/initiate, POST /payments/refundArbitrary 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..."
}
StatusWhen
400Malformed body, missing required field, bad UUID
401Missing / invalid JWT, webhook signature mismatch
403Tenant mismatch (e.g. propertyId belongs to another tenant)
404Unknown paymentId, merchantAccountId, etc.
409Idempotency conflict (same key, different body); mode is immutable after first success
422Business-rule failure — PROVIDER_DECLINED, DEVICE_OFFLINE, MERCHANT_ACCOUNT_INACTIVE
429Rate limit exceeded (gateway middleware or per-tenant cap)
500Unexpected server error; correlation ID in response body
503Provider unreachable after retries; Retry-After header set
504Provider 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.value is in the currency's smallest unit — paise for INR.
  • useCase is one of GUEST_BOOKING_DEPOSIT, GUEST_FOLIO_SETTLEMENT, POS_ORDER_PAYMENT, BANQUET_EVENT_PAYMENT, SPA_SERVICE_PAYMENT, SUBSCRIPTION, PAYMENT_LINK_ADHOC, OTHER.
  • externalReferenceType is one of RESERVATION, 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

StatusErrorMeaning
400VALIDATION_ERRORMissing field, bad amount, unknown useCase
403TENANT_MISMATCHpropertyId doesn't belong to JWT tenant
404MERCHANT_ACCOUNT_NOT_FOUNDNo active account for (tenantId, propertyId, provider)
409IDEMPOTENCY_CONFLICTKey already used with different body
422MERCHANT_ACCOUNT_INACTIVEAccount deactivated (typically after repeated auth errors)
503PROVIDER_UNREACHABLENetwork 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

StatusError
403TENANT_MISMATCH — payment belongs to a different tenant
404PAYMENT_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 amount for a full refund; supply it for a partial refund. Partial refunds require the provider to declare PARTIAL_REFUND capability.
  • 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

StatusError
404PAYMENT_NOT_FOUND
409PAYMENT_NOT_REFUNDABLE — payment not SUCCEEDED, or already fully refunded
422REFUND_AMOUNT_EXCEEDS_REMAINING
422PROVIDER_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: null makes the account the tenant-wide default. Supply a UUID to scope it to one property (franchise / multi-entity chains).
  • mode must 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().

Mode is immutable

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)

StatusError
400VALIDATION_ERROR — mode / key prefix mismatch, missing field
403TENANT_MISMATCH
404MERCHANT_ACCOUNT_NOT_FOUND
409DUPLICATE_ACTIVE_ACCOUNT — another active account exists for (tenantId, propertyId, providerCode)
422CREDENTIAL_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 paramExampleMeaning
providerCoderazorpay-gatewayMatches ProviderCatalog.code
merchantAccountIdm0a1b2c3-...UUID — opaque; never reveals tenantId

Headers

HeaderRequiredProvider
X-Razorpay-Signatureyes (Razorpay)HMAC-SHA256 of raw body using webhook secret
X-Razorpay-Event-Idyes (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

  1. Insert WebhookEvent row with processingStatus = RECEIVED, signatureVerified = false.
  2. Dedup on (providerType, providerEventId) — duplicates return 200 OK with no side effects.
  3. Lookup MerchantAccount by path param; load decrypted webhookSecret.
  4. Verify signature. Mismatch → 401, WebhookEvent.processingStatus = FAILED.
  5. adapter.verifyAndParseWebhook() canonicalises to ProviderWebhookEvent.
  6. Dispatch: payment.captured → Attempt CAPTURED + Payment SUCCEEDED; payment.failed → Attempt FAILED; refund.processed → Refund COMPLETED.
  7. Emit domain event on Redis Streams (payment.succeeded, etc.).
  8. WebhookEvent.processingStatus = PROCESSED, return 200 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​

ScopeLimit
Gateway per-IP (all endpoints)600 req / 5 min
POST /payments/initiate per tenant50 / min (tunable in tenant config)
POST /payment-links/:slug/attempt per link20 / lifetime (card-testing defence)
Guest-facing link landing per IP10 / 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:

TopicEmitted whenPayload
payment.initiatedPayment created{ paymentId, tenantId, propertyId, useCase, externalReferenceType, externalReferenceId, amount }
payment.succeededAttempt captured → Payment success{ paymentId, attemptId, amount, providerCode, externalReferenceType, externalReferenceId }
payment.failedAttempt final failure (no retry pending){ paymentId, attemptId, failureReason }
refund.processedRefund completed{ refundId, paymentId, amount }
link.created / link.paid / link.expiredPaymentLink lifecycle{ linkId, paymentId, slug, ... }
terminal.paired / terminal.offlineDevice lifecycle{ deviceId, tenantId, propertyId, status }

Consumer subscription pattern: see analytics-event-flow and the SKIP_EVENT_SUBSCRIPTIONS flag convention.


See also​