A Singapore Government Agency Website

system-integrator Payout API (0.1.0-draft)

Download OpenAPI specification:Download

DRAFT

SFEC payout orchestration. Payload matches the OCBC payout format. sfec verifies proxyValue against merchant UEN and amount against the Collections transaction before proceeding.

Steps: transaction lookup → Workday budget check → journal → earmark cancel → wallet-core PayNow (OCBC) → reverse journal → adhoc bank transaction.

Option B flow: The E1 earmark is cancelled before PayNow fires, releasing the held balance so that makePaynowCreate can deduct from the free balance. PayNow uses saga.ID as idempotency key to prevent duplicate payments on retry.

Auth: Cognito client-credentials JWT. Scopes: wallet-core/sfec:payout:create (POST /sfec/payout/create) and wallet-core/sfec:payout:enquire (GET /sfec/payout/{govwalletTransactionId}). sfec uses its own service-to-service token for Collections reads — callers do not need wallet-core/transaction:read.

JWE Envelope: POST request bodies, and all responses except 401/403, use JWE (JSON Web Encryption) with RSA-OAEP-512 + AES-256-GCM.

  • POST requests: Send as application/x-www-form-urlencoded with a single field payload containing the JWE compact serialization of the JSON request body.
  • GET requests: No request body and no JWE. Send the transaction ID in the path and the campaign in the query. Do not send a payload parameter. SFEC ignores it.
  • All responses except 401/403: Returned as raw JWE compact token with Content-Type: application/jose. Decrypt with the private key to obtain the JSON response body.

Idempotency: Idempotency-Key header required. Same key resumes from last persisted step.

Account status: Every POST /sfec/payout/create request (new or retry with the same Idempotency-Key, whatever the payout's status, including pending_settlement) first checks the customer's wallet account. It returns 400 CUSTOMER_ACCOUNT_NOT_FOUND if the account does not exist, 422 CUSTOMER_ACCOUNT_DEACTIVATED if it is DEACTIVATED, or 500 CUSTOMER_ACCOUNT_LOOKUP_FAILED if the status could not be verified. In each case the existing payout is left unchanged.

Settlement: OCBC PayNow settles in up to 2170s. Returns 202 pending_settlement with pollAfterSeconds: 60; retry POST /sfec/payout/create with the same Idempotency-Key after 2170s. If still pending_settlement after 2170s, stop retrying and escalate manually.

Failed PayNow payment (rsfec only): If OCBC rejects or cancels the PayNow payment, the API returns 422 PAYNOW_FAILED_FUNDS_HELD, not 422 STEP_FAILED. The payout amount is held, not returned to the customer's balance. Wait at least 24 hours, then retry POST /sfec/payout/create with the same Idempotency-Key and the same request body. GovWallet makes one new PayNow payment with the held amount. A retry within 24 hours returns the same 422 and makes no new payment.


Changes

2026-10-29

  • Add EARMARK_EXPIRED (422, in data.error.code): the earmark's cartExpiry has passed. TRANSACTION_INVALID_STATE is now also returned when the earmark is cancelled or pending cancel. Both are checked at transaction lookup, before any Workday step, and are final: a retry with the same Idempotency-Key returns the same failure

2026-10-23

  • Add PAYNOW_FAILED_FUNDS_HELD (422, rsfec only): OCBC rejected or cancelled the PayNow payment and the payout amount is held. Retry with the same Idempotency-Key after at least 24 hours to make one new payment. A retry within 24 hours returns the same 422
  • Remove OCBC_PAYNOW_FAILURE from the error code enum and the 502 examples. SFEC does not return it
  • Correct pollAfterSeconds in the settlement description from 2170 to 60

2026-10-19

  • Corrected the JWE description: only POST request bodies use JWE. GET /sfec/payout/{govwalletTransactionId} has no request body, and SFEC ignores any payload parameter on it. No API behaviour change

2026-10-16

  • POST /sfec/payout/create checks the proxyValue format for its proxyType before any saga write. A value in the wrong format returns 400 VALIDATION_ERROR with details.field = proxyValue. The response does not include the value

2026-10-09

  • proxyType accepts only U, M or N. The long forms UEN, MOBILE and NRIC are no longer accepted and return 422 INVALID_PROXY_TYPE

2026-09-30

  • Add customer wallet account status check on every POST /sfec/payout/create request — 400 CUSTOMER_ACCOUNT_NOT_FOUND when the account does not exist; 422 CUSTOMER_ACCOUNT_DEACTIVATED when the account is DEACTIVATED; 500 CUSTOMER_ACCOUNT_LOOKUP_FAILED when the lookup fails or the status is missing or unrecognised. All use ErrorResponse
  • Add CUSTOMER_ACCOUNT_NOT_FOUND, CUSTOMER_ACCOUNT_DEACTIVATED and CUSTOMER_ACCOUNT_LOOKUP_FAILED to error code enum

2026-08-20

  • Fix stale OAuth scope documentation — spec previously referenced system-integrator/payout, which was never a real Cognito scope. Code and Cognito already enforce wallet-core/sfec:payout:create (POST) and wallet-core/sfec:payout:enquire (GET); this only corrects the docs to match.

2026-07-31

  • Add JWE envelope requirement — POST request body is now application/x-www-form-urlencoded with payload field (JWE compact token); all responses use Content-Type: application/jose (JWE compact token)
  • Add JWE_DECRYPT_FAILED to error code enum — returned as plain JSON (400) when payload field is missing or JWE decryption fails
  • Algorithm: RSA-OAEP-512 + AES-256-GCM

2026-07-30

  • PayoutStatusData (GET response): added completedAt, error, and pollAfterSeconds — now matches PayoutData (POST response) for symmetry
  • PayoutError component schema: added as a named schema (shared by PayoutData.error and PayoutStatusData.error)
  • completedAt description corrected: only set when payoutStatus = success (not on failure)

2026-07-21

  • Add CAMPAIGN_MISMATCH to error code enum — implementation raises this when the earmark's campaign does not match the request
  • Expand proxyType enum — now accepts both short form (U/M/N) and long form (UEN/MOBILE/NRIC)
  • Fix customerId description — corrected to "SFEC customer ID. For SFEC, this is the company UEN (same as proxyValue)"
  • Fix 202 response description — updated to cover both processing (saga still running) and pending_settlement (OCBC settling), and reference pollAfterSeconds
  • payoutStatus enum — expanded from ["success", "pending_settlement"] to full set ["processing", "pending_settlement", "success", "failed"]
  • Rename PAYOUT_IN_PROGRESS → PAYOUT_EXISTS — matches what the implementation actually returns
  • Remove completedAt from PayoutStep objects — field does not exist at step level

Payout

SFEC payout orchestration

Trigger a payout for an earmarked transaction

Runs the payout saga: transaction lookup → budget check → journal → earmark cancel → PayNow → reverse journal → bank transaction.

Requires a prior EARMARK_CREATE via Collections. sfec verifies proxyValue and amount against the transaction record, and on every request checks that the customer's wallet account exists and is not DEACTIVATED. The E1 earmark is cancelled before PayNow fires (Option B), releasing the held balance so the PayNow transfer can proceed.

Authorizations:
cognitoJwt
header Parameters
Idempotency-Key
required
string <uuid> <= 64 characters

Unique key per payout attempt (UUID recommended). Same key resumes the existing saga.

Request Body schema: application/x-www-form-urlencoded
required

JWE-encrypted request. Send as application/x-www-form-urlencoded with a single field payload containing the JWE compact serialization. The JWE plaintext is the JSON body described by PayoutRequest.

payload
required
string

JWE compact serialization (5 base64url-encoded parts separated by dots). Decrypt to obtain the PayoutRequest JSON body. Algorithm: RSA-OAEP-512, Encryption: A256GCM.

Responses

Request samples

Content type
application/x-www-form-urlencoded

The payload field value is the JWE compact token. When decrypted, the plaintext JSON is:

payload=eyJhbGciOiJSU0EtT0FFUC01MTIiLCJlbmMiOiJBMjU2R0NNIn0.%3C...%3E.iv.ciphertext.tag

Response samples

Content type
application/jose
{
  "data": {
    "payoutStatus": "success",
    "govwalletTransactionId": "6f1c2e8a-4b3d-4c9a-9e2f-1a7b8c9d0e1f",
    "customerId": "52871152X",
    "campaign": "rsfec-stg",
    "proxyType": "U",
    "proxyValue": "52871152X",
    "amount": 10000,
    "description": "wis",
    "currency": "SGD",
    "references": {
      "workdayJournalId": "wis-JRNL",
      "workdayReversalJournalId": "wis-RJRNL",
      "workdayBankTransactionId": "wis-BTXN",
      "paynowTxnId": "govwallet-paynow-txn-001"
    },
    "completedAt": "2026-07-09T08:41:22Z"
  },
  "meta": {
    "requestId": "b2f0a1d4-7c8e-4f2a-9b3c-5d6e7f8a9b0c",
    "timestamp": "2026-07-09T08:41:22Z"
  }
}

Get payout status and step progress

Current saga state. Poll after 202 pending_settlement, or use for reconciliation.

The request has no body and is not JWE-encrypted. Do not send a payload parameter. SFEC ignores it. The response is JWE-encrypted to your registered public key, except 401/403.

Authorizations:
cognitoJwt
path Parameters
govwalletTransactionId
required
string <uuid>

The govwalletTransactionId from the original payout request (UUID from Collections earmark).

query Parameters
campaign
required
string

Campaign identifier. Must match the campaign used in the original payout request. Returns 404 if mismatched (prevents cross-campaign information leakage).

Responses

Response samples

Content type
application/jose
{
  "data": {
    "payoutStatus": "pending_settlement",
    "govwalletTransactionId": "6f1c2e8a-4b3d-4c9a-9e2f-1a7b8c9d0e1f",
    "customerId": "52871152X",
    "campaign": "rsfec-stg",
    "proxyType": "U",
    "proxyValue": "52871152X",
    "amount": 10000,
    "description": "wis",
    "currency": "SGD",
    "steps": [
      {
        "step": "TRANSACTION_LOOKUP",
        "status": "completed"
      },
      {
        "step": "BUDGET_CHECK",
        "status": "completed"
      },
      {
        "step": "SUBMIT_JOURNAL",
        "status": "completed",
        "reference": "wis-JRNL"
      },
      {
        "step": "EARMARK_CANCEL",
        "status": "completed"
      },
      {
        "step": "PAYNOW_PAYOUT",
        "status": "pending",
        "reference": "wis"
      },
      {
        "step": "REVERSE_JOURNAL",
        "status": "not_started"
      },
      {
        "step": "ADHOC_BANK_TRANSACTION",
        "status": "not_started"
      }
    ],
    "updatedAt": "2026-07-09T08:14:05Z"
  },
  "meta": {
    "requestId": "c3d1b2e5-8d9f-4a3b-8c4d-6e7f8a9b0c1d",
    "timestamp": "2026-07-09T08:14:05Z"
  }
}
Chat with me!
Bot Launcher