API v1.0
Updated 2026-07-10

FunTokenz Merchant API

A prepaid voucher system for merchants who want to accept deposits and issue withdrawals in USD or PGK without card infrastructure. Authenticated with HMAC-SHA256.

Deposit flow

Redeem a customer-supplied voucher code and credit their wallet.

Withdrawal flow

Debit a customer balance and issue a fresh FunTokenz voucher.

Webhooks

Receive signed events for redemptions, voids and reversals.

Base URLs

Production
https://funtokenz.com/api/public/merchant/v1
Sandbox
https://project--a8d167f0-2759-414d-9002-033c558df52b-dev.lovable.app/api/public/merchant/v1

All amounts are integer minor units (cents for USD, toea for PGK). Supported currencies: USD, PGK.

0. Provisioning

Credentials are minted in the FunTokenz admin console. Each merchant receives:

  • merchant_id (slug, e.g. puntpal)
  • api_key (public identifier)
  • api_secret — HMAC signing key, shown once
  • webhook_secret — shown once

Both secrets can be rotated from the admin console; rotation invalidates the previous secret immediately. Use separate merchant records for sandbox and production.

1. Authentication

Every request is authenticated with the merchant API key plus an HMAC-SHA256 signature over the raw request body.

Required headers

code
Content-Type:       application/json
X-FTZ-Merchant-Id:  puntpal
X-FTZ-Api-Key:      pk_live_xxxxxxxxxxxx
X-FTZ-Timestamp:    1751500000            # Unix seconds, within ±300s of server time
X-FTZ-Nonce:        550e8400-e29b-41d4-a716-446655440000
X-FTZ-Signature:    sha256=<hex-hmac>

Signature construction

code
string_to_sign = X-FTZ-Timestamp + "." + X-FTZ-Nonce + "." + HTTP_METHOD + "." + REQUEST_PATH + "." + RAW_BODY
signature      = hex( HMAC_SHA256( api_secret, string_to_sign ) )
  • HTTP_METHOD is uppercase (POST).
  • REQUEST_PATH starts with /api/public/merchant/v1/... and includes any query string.
  • RAW_BODY is the exact request body bytes (empty string for GET).
  • The server compares signatures with a constant-time equality check.

Node.js example

typescript
import { createHmac, randomUUID } from "node:crypto";

const body  = JSON.stringify(payload);
const ts    = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
const stringToSign = `${ts}.${nonce}.POST./api/public/merchant/v1/deposit/redeem.${body}`;
const signature = createHmac("sha256", process.env.FTZ_API_SECRET!)
  .update(stringToSign).digest("hex");

await fetch("https://funtokenz.com/api/public/merchant/v1/deposit/redeem", {
  method: "POST",
  headers: {
    "Content-Type":      "application/json",
    "X-FTZ-Merchant-Id": "puntpal",
    "X-FTZ-Api-Key":     process.env.FTZ_API_KEY!,
    "X-FTZ-Timestamp":   ts,
    "X-FTZ-Nonce":       nonce,
    "X-FTZ-Signature":   `sha256=${signature}`,
  },
  body,
});

Every response includes an X-FTZ-Request-Id header — log it against your transaction row for support.

2. Idempotency

State-changing endpoints require an idempotency_key UUID in the body.

  • Retrying the same key returns the original response with "idempotent_replay": true.
  • Reusing a key with a different payload returns 409 idempotency_conflict.
  • merchant_txn_id is unique per merchant per endpoint — reusing it also returns 409.

3.1 POST /deposit/verify (optional)

Look up a voucher without redeeming it — useful for previewing the amount before the customer confirms.

Request

json
{ "code": "FTZ-ABCD-EFGH-JKLM" }

Response 200

json
{
  "code":      "FTZ-ABCD-EFGH-JKLM",
  "status":    "active",
  "amount":    5000,
  "currency":  "USD",
  "issued_at": "2026-07-10T09:12:33.000Z"
}

Status values

StatusMeaning
activeRedeemable
redeemedSpent via POST /deposit/redeem by a merchant (final)
voidedCashed in by the customer on funtokenz.com or at a reseller/cash point, or manually voided by staff (final)
expiredPast expires_at
unknownCode was never issued

Only active vouchers may be redeemed. redeemed, voided and expired are terminal and cannot be used again in the deposit flow. A reversed redemption returns the voucher to active so it can be deposited again.

Complete error list for /deposit/verify

Verify never returns a voucher-state error — unknown, spent and expired codes all return 200 with the status above. These are the only error responses it can produce (they also apply to every other endpoint):

HTTPerror.codeMeaning
400invalid_jsonBody is not valid JSON
400invalid_requestcode missing or empty
401missing_headerAn X-FTZ-* header is absent
401invalid_timestampTimestamp not an integer, or outside the ±300s window
401invalid_signatureHMAC did not verify
401invalid_api_keyUnknown merchant id / API key pair
403merchant_disabledMerchant account is disabled
500internal_errorUnexpected server-side failure — safe to retry

3.2 POST /deposit/redeem

Atomically moves the voucher from active to redeemed and records a redemption against the merchant. Call from the merchant backend only, never the customer's browser.

Request

json
{
  "idempotency_key":    "b6f3b0b2-8f5e-4a2d-9a0e-1c1c9a5b3e11",
  "code":               "FTZ-ABCD-EFGH-JKLM",
  "customer_reference": "puntpal_user_9812",
  "merchant_txn_id":    "deposit_2026071011223301"
}

Fields

FieldTypeRequiredNotes
idempotency_keyuuidyesSee §2
codestringyesFull voucher code, case-insensitive
customer_referencestring ≤ 128yesMerchant-side user id
merchant_txn_idstring ≤ 64yesUnique per merchant deposit id

Response 200

json
{
  "redemption_id":     "6f0c8b3e-2ac1-4b7d-a5c8-2f1e3d4b5c67",
  "code":              "FTZ-ABCD-EFGH-JKLM",
  "amount":            5000,
  "currency":          "USD",
  "redeemed_at":       "2026-07-10T11:22:33.412Z",
  "idempotent_replay": false
}

Common errors

HTTPerror.codeMeaning
404voucher_not_foundCode does not exist
409voucher_already_redeemedCode was already spent
409voucher_voidedCashed in outside the merchant API or voided by staff
410voucher_expiredPast its expiry date
409idempotency_conflictKey or merchant_txn_id reused

3.3 POST /transactions/status

Read-only status lookup for a redemption or an issuance belonging to your merchant. Use it for reconciliation after a timeout or technical error — it never changes state.

Request (supply exactly one identifier)

json
{ "redemption_id": "6f0c8b3e-2ac1-4b7d-a5c8-2f1e3d4b5c67" }
FieldTypeRequiredNotes
redemption_iduuidone ofFrom /deposit/redeem
issuance_iduuidone ofFrom /withdrawal/issue
merchant_txn_idstringone ofYour own transaction id
codestringone ofVoucher code

Response 200

json
{
  "transaction_type":   "deposit_redeem",
  "transaction_status": "succeeded",
  "redemption_id":      "6f0c8b3e-2ac1-4b7d-a5c8-2f1e3d4b5c67",
  "issuance_id":        null,
  "merchant_txn_id":    "deposit_2026071011223301",
  "customer_reference": "puntpal_user_9812",
  "code":               "FTZ-ABCD-EFGH-JKLM",
  "voucher_status":     "redeemed",
  "amount":             5000,
  "currency":           "USD",
  "issued_at":          "2026-07-10T09:12:33.000Z",
  "redeemed_at":        "2026-07-10T11:22:33.412Z",
  "expires_at":         "2027-07-10T09:12:33.000Z"
}

transaction_status values

HTTPerror.codeMeaning
succeededfinalRedeem completed, or voucher minted successfully
expiredissuance onlyVoucher expired unused

Errors

HTTPerror.codeMeaning
400invalid_requestNo identifier supplied
404voucher_not_foundNo matching transaction for this merchant

4.1 POST /withdrawal/issue

Debit the customer's balance and mint a fresh voucher code returned to the customer. If customer_email is sent, the voucher is added to that customer's FunTokenz account (created automatically if needed) and FunTokenz emails them the voucher. Without an email the code is bearer.

Request

json
{
  "idempotency_key":    "d1a2c9f4-1e23-4a10-9b7f-2c0f4e6a01f2",
  "amount":             5000,
  "currency":           "USD",
  "customer_reference": "puntpal_user_9812",
  "customer_email":     "player@example.com",
  "merchant_txn_id":    "withdraw_2026071011400712"
}

Fields

FieldTypeRequiredNotes
idempotency_keyuuidyesSee §2
amountintegeryesMinor units. USD: 100–500 000. PGK: 500–1 500 000
currencyUSD | PGKyesMust be enabled for the merchant
customer_emailstringnoVoucher added to this customer's FunTokenz account (created if needed) and emailed to them
customer_referencestring ≤ 128yesMerchant user id
merchant_txn_idstring ≤ 64yesUnique per merchant withdrawal id

Response 200

json
{
  "issuance_id":       "2c1d4f60-b7a8-4e11-9d3a-4a1b6e2f8a09",
  "code":              "FTZ-QWER-TYUI-OPAS",
  "amount":            5000,
  "currency":          "USD",
  "expires_at":        "2027-07-10T11:40:07.000Z",
  "issued_at":         "2026-07-10T11:40:07.412Z",
  "idempotent_replay": false
}

Merchant must

  1. Confirm the customer's balance covers amount + fees before calling.
  2. Debit the customer in the same DB transaction that stores issuance_id / code.
  3. Present code to the customer once. Never log it in plaintext outside the customer's account view.

Common errors

HTTPerror.codeMeaning
400invalid_amountBelow/above allowed range
400currency_not_enabledMerchant not enabled for that currency
402merchant_credit_exhaustedDaily issuance limit reached
409idempotency_conflictKey or merchant_txn_id reused

5. Webhooks (optional)

Configure a webhook URL when provisioning the merchant. FunTokenz posts JSON events signed with the merchant's webhook_secret.

  • POST with JSON body.
  • Headers include X-FTZ-Webhook-Signature: sha256=<hex-hmac> and X-FTZ-Event-Id.
  • Endpoint must return any 2xx within 5 s. The response body is ignored — an empty {} is fine.
  • Delivery is currently single-attempt, best-effort (no retry queue). API responses are synchronous and authoritative; reconcile from them, not from webhook receipt.
  • Handle events idempotently on id.

Event types (live)

EventWhen
voucher.issuedA /withdrawal/issue call succeeded — the withdrawal is final. Sent to the issuing merchant
voucher.redeemedA /deposit/redeem call succeeded — the deposit is final. Sent to the redeeming merchant, and to the issuing merchant if different
voucher.voidedFunTokenz staff manually voided a voucher affecting this merchant
redemption.reversedA previously accepted deposit was reversed — debit the customer. The voucher returns to active and may be deposited again

There is no partial-redemption event because partial redemption does not exist — every successful redeem consumes the whole voucher. Treat voucher.issued / voucher.redeemed as success confirmations of transactions that were already final in the synchronous API response; webhooks are for reconciliation and async notification, not for detecting failures (failures are returned synchronously as error responses).

Example payload

json
{
  "id":         "evt_01J8ZA1MK0X6C4C7Q4Q0F1RTPB",
  "type":       "voucher.redeemed",
  "created_at": "2026-07-10T12:00:00.000Z",
  "data": {
    "issuance_id": "2c1d4f60-b7a8-4e11-9d3a-4a1b6e2f8a09",
    "code":        "FTZ-QWER-TYUI-OPAS",
    "amount":      5000,
    "currency":    "USD",
    "redeemed_at": "2026-07-10T11:59:58.000Z"
  }
}

Signature verification

typescript
import { createHmac, timingSafeEqual } from "node:crypto";

const raw = await request.text();
const sig = request.headers.get("x-ftz-webhook-signature")!.slice(7);
const expected = createHmac("sha256", process.env.FTZ_WEBHOOK_SECRET!)
  .update(raw).digest("hex");
const ok = sig.length === expected.length &&
  timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));

6. Merchant credit / float

/withdrawal/issue is capped by a daily issuance limit per currency (defaults: 50 000 USD, 150 000 PGK). Contact FunTokenz admin to raise. Deposit endpoints have no cap beyond global rate limits.

7. Standard error shape

All 4xx / 5xx responses use this shape:

json
{
  "error": {
    "code":       "voucher_already_redeemed",
    "message":    "This voucher has already been redeemed.",
    "request_id": "req_5a3f0c8b3e2ac14b7da5c82f1e3d4b5c"
  }
}

HTTP status reference

HTTPMeaning
400Malformed request / validation failure
401Missing or invalid API key or signature
403Merchant disabled
404Resource not found
409State conflict (already spent, idempotency mismatch)
410Resource expired
429Rate limited
5xxFunTokenz-side failure — retry with the same idempotency_key

8. Currencies & amounts

CurrencyMin per voucherMax per voucherMinor unit
USD100 ($1.00)500 000 ($5 000.00)cents
PGK500 (K5.00)1 500 000 (K15 000.00)toea

No FX conversion is performed. A PGK voucher must be issued and redeemed as PGK.

9. Security requirements

  • Store api_secret and webhook_secret in a secrets manager. Never commit them.
  • Enforce HTTPS on the webhook endpoint; reject any request whose HMAC does not verify.
  • Reject webhook requests whose created_at is older than 5 minutes.
  • Do not expose FunTokenz API endpoints or credentials to browser code — all calls originate from the merchant backend.
  • Log X-FTZ-Request-Id and X-FTZ-Event-Id alongside every transaction for reconciliation.

10. Testing

Use a separate sandbox merchant record against the sandbox base URL. Issue a small voucher via /withdrawal/issue, then verify + redeem it via /deposit/verify and /deposit/redeem using its own code.

Curl smoke test for /deposit/verify

bash
BODY='{"code":"FTZ-XXXX-XXXX-XXXX"}'
TS=$(date +%s)
NONCE=$(uuidgen)
PATH_="/api/public/merchant/v1/deposit/verify"
SIG=$(printf "%s.%s.POST.%s.%s" "$TS" "$NONCE" "$PATH_" "$BODY" \
  | openssl dgst -sha256 -hmac "$FTZ_API_SECRET" | awk '{print $2}')

curl -s https://funtokenz.com$PATH_ \
  -H "Content-Type: application/json" \
  -H "X-FTZ-Merchant-Id: puntpal" \
  -H "X-FTZ-Api-Key: $FTZ_API_KEY" \
  -H "X-FTZ-Timestamp: $TS" \
  -H "X-FTZ-Nonce: $NONCE" \
  -H "X-FTZ-Signature: sha256=$SIG" \
  -d "$BODY"

11. Changelog

  • v1.0 (2026-07-10) — Initial release: HMAC auth, deposit verify + redeem, withdrawal issue, webhook delivery.

Appendix A — Onboarding checklist

  1. FunTokenz provisions sandbox merchant → shares credentials via a secure channel.
  2. Merchant integrates deposit (redeem) flow; verifies expected error codes.
  3. Merchant integrates withdrawal (issue) flow; verifies balance debit + code display.
  4. Merchant registers webhook URL; FunTokenz confirms signature verification.
  5. Reconciliation smoke test: N deposits + N withdrawals; both sides match on merchant_txn_id.
  6. Production merchant provisioned; go-live scheduled.

Questions? Contact the FunTokenz integrations team with your merchant_id and a recent X-FTZ-Request-Id.