# GCash Scan User API v1.1 Canonical machine contract: [`./openapi.json`](./openapi.json) Base URL: `https://scan-api.fengl.cc/api/public/v1` This document is deliberately structured for both humans and AI clients. All v1.0 requests remain valid: every new request field is optional and the original five endpoints keep their methods and paths. ## Authentication and common rules Use one header: ```http X-API-Key: gcs_YOUR_API_KEY Authorization: Bearer gcs_YOUR_API_KEY ``` - `Bearer` is case-sensitive. - Each response includes `X-Request-ID`. - Each API key is limited to 120 requests per UTC minute. HTTP `429` includes `Retry-After: 60`. - Money is a decimal string with exactly two fractional digits. Unit is always `"U"` (USDT). - Timestamps are UTC RFC 3339 strings. Unset timestamps and optional result fields are JSON `null`. - `task_no` and `order_no` are 26-character ULIDs. - Every API error has a stable machine `error` code and human-readable `message`. ## GET /balance ```json { "available": "25.50", "locked": "1.00", "unit": "U", "extraction_fee": "1.00", "order_fee": "1.00", "channels": [ {"link_type":"gcash","link_type_label":"GCash","scan_fee":"1.00","extract_fee":"1.00"} ] } ``` The legacy `extraction_fee` and `order_fee` fields remain as default compatibility values. For channel-specific pricing, read `channels[].scan_fee` for QR orders and `channels[].extract_fee` for extraction tasks. The server still performs the authoritative atomic balance check. ## POST /extractions Content type: `application/json`. ```json { "access_token": "FULL_ACCESS_TOKEN", "client_request_id": "local-batch-email-hash-001", "email": "user@example.com", "metadata": { "batch_id": "20260807_001", "channel": "gc-plus" }, "callback_url": "https://hooks.example.com/gc-plus/callback", "callback_secret": "optional-secret" } ``` Fields: | Field | Required | Constraints | |---|---:|---| | `access_token` | yes | 20-8192 characters after trimming. Forwarded only; never persisted or logged. | | `client_request_id` | no | 1-128 path-safe characters matching `^[A-Za-z0-9][A-Za-z0-9._:@+=-]{0,127}$`. | | `email` | no | Valid email, maximum 320 characters. | | `metadata` | no | JSON object, encoded size at most 16 KiB. | | `callback_url` | no | Absolute HTTP or HTTPS URL, maximum 2048 characters. The host must resolve to a public IP; loopback, private, and link-local destinations are rejected. | | `callback_secret` | no | Maximum 512 bytes; requires `callback_url`; encrypted at rest and never returned. | The service stores only a SHA-256 fingerprint of the trimmed access token for idempotency. First creation returns HTTP `202` and `idempotent_replay: false`. The fee is reserved once. ```json { "task_no": "01K20B2CDEFG3HJ4KM5NP6QRST", "client_request_id": "local-batch-email-hash-001", "linked_order_no": null, "status": "QUEUED", "stage": "queued", "fee": "1.00", "unit": "U", "long_url": null, "copy_paste": null, "image_url_png": null, "error": null, "message": null, "email": "user@example.com", "metadata": {"batch_id":"20260807_001","channel":"gc-plus"}, "created_at": "2026-08-07T08:20:00Z", "started_at": "2026-08-07T08:20:01Z", "finished_at": null, "expires_at": "2026-08-07T08:30:00Z", "idempotent_replay": false } ``` Idempotency is scoped to the authenticated API user: - Same `client_request_id` and same trimmed access-token fingerprint: HTTP `200`, existing task, `idempotent_replay: true`, no second charge. - Same `client_request_id` and different token fingerprint: HTTP `409` with `idempotency_conflict`. - Omitted `client_request_id`: legacy behavior; every POST creates a new task. ## GET /extractions/{task_no} Returns the extraction object above without `idempotent_replay`. A successful result has `status: "SUCCESS"`, `stage: "url_ready"`, and at least one of `long_url` or `copy_paste`. If the extractor reports success without either URL, the task fails with `no_url_returned` and the reserved fee is released. ## GET /extractions/by-client-request/{client_request_id} Returns the same object as `GET /extractions/{task_no}`. Use it after a local timeout, disconnect, or restart to determine whether the previous POST succeeded before retrying creation. ## POST /orders Content type: `multipart/form-data`. ```text qr=@payment-qr.png client_request_id=local-batch-email-order-001 task_no=01K20B2CDEFG3HJ4KM5NP6QRST email=user@example.com callback_url=https://hooks.example.com/gc-plus/callback callback_secret=optional-secret ``` Only `qr` is required. PNG, JPEG, WebP, and GIF are accepted, maximum 10 MiB. `task_no` must identify an extraction owned by the same API user. One extraction can link to only one order. When callback fields are omitted and `task_no` is supplied, callback configuration is inherited from the extraction. First creation returns HTTP `201` and `idempotent_replay: false`: ```json { "order_no": "01K20A1BCDEF2GH3JK4MNP5QRS", "task_no": "01K20B2CDEFG3HJ4KM5NP6QRST", "client_request_id": "local-batch-email-order-001", "status": "QUEUED", "stage": "queued", "fee": "1.00", "unit": "U", "error": null, "message": null, "email": "user@example.com", "queued_at": "2026-08-07T08:20:00Z", "claimed_at": null, "completed_at": null, "expires_at": "2026-08-07T08:30:00Z", "idempotent_replay": false } ``` Order idempotency uses SHA-256 of the exact raw QR bytes: - Same `client_request_id` and identical QR bytes: HTTP `200`, existing order, `idempotent_replay: true`, no second charge. - Same `client_request_id` and different QR bytes: HTTP `409` with `idempotency_conflict`. - Omitted `client_request_id`: legacy behavior; every POST creates a new order. ## GET /orders/{order_no} Returns the order object above without `idempotent_replay`. A linked completed order includes `task_no`, `status: "COMPLETED"`, and `stage: "scan_completed"`. The linked extraction then returns this order in `linked_order_no`. ## GET /orders/by-client-request/{client_request_id} Returns the same object as `GET /orders/{order_no}`. Use this recovery endpoint before repeating an order POST whose result was not received. ## Callbacks / webhooks Callbacks are queued durably in the same database transaction as each API-visible state or stage change. Delivery failure never reverses the task, order, or financial transaction. - Method: `POST` - Content type: `application/json` - Success acknowledgement: any HTTP `2xx` - Redirects: not followed; a `3xx` response is treated as a delivery failure - `X-Request-ID`: stable for all attempts of one event - `X-Signature`: present only when `callback_secret` was supplied - Signature: lowercase hex `HMAC-SHA256(callback_secret, exact_raw_json_body)` with no prefix - Attempts: initial delivery plus five retries - Retry delays after failures: approximately 1 second, 5 seconds, 30 seconds, 2 minutes, and 10 minutes Verify the signature against the raw bytes before JSON decoding. Receivers should deduplicate by `X-Request-ID` and return `2xx` quickly. Extraction callback (`type: "extraction.updated"`) includes: `task_no`, `client_request_id`, `status`, `stage`, result URLs, `error`, `message`, fee/unit, and timestamps. Order callback (`type: "order.updated"`) includes: `order_no`, linked `task_no`, `client_request_id`, `status`, `stage`, `error`, `message`, fee/unit, and timestamps. ## Status and stage mapping Extraction: | Status | Stage | Meaning | |---|---|---| | `CREATED` | `created` | Local row and fee reservation committed; external submission is in progress. | | `QUEUED` | `queued` | Extractor accepted the task. | | `RUNNING` | `running` | Extractor is processing. | | `SUCCESS` | `url_ready` | URL is ready and fee captured. | | `FAILED` | `failed` | Failed; reserved fee released. | | `EXPIRED` | `expired` | Ten-minute task lifetime exceeded; fee released. | Order: | Status | Stage | Meaning | |---|---|---| | `QUEUED` | `queued` | Waiting for a worker; fee reserved. | | `CLAIMED` | `claimed` | Worker claimed the order. | | `CLAIMED` | `waiting_scan` | Worker successfully opened the QR and is scanning. | | `COMPLETED` | `scan_completed` | Scan completed; fee captured. | | `CANCELLED` | `scan_failed` | Worker reported failure; fee released. | | `EXPIRED` | `expired` | QR or order expired; fee released. | | `REFUNDED` | `refunded` | Administrator completed an idempotent refund. | `cancelled` is reserved in the order stage enum for forward-compatible cancellation workflows. ## Stable error codes | HTTP | Error codes | |---:|---| | 400 | `invalid_request`, `invalid_access_token`, `qr_required`, `invalid_qr`, `invalid_client_request_id`, `invalid_email`, `invalid_metadata`, `invalid_callback_url`, `invalid_callback_secret` | | 401 | `invalid_api_key` | | 402 | `insufficient_balance` | | 403 | `ip_not_allowed`, `user_disabled` | | 404 | `not_found`, `task_not_found` | | 409 | `idempotency_conflict`, `task_already_linked`, `qr_expired` | | 415 | `unsupported_image` | | 429 | `rate_limit_exceeded` | | 500 | `internal_error` | | 502 | `extraction_submit_failed` | | 503 | `extractor_unavailable`, `internal_error` for temporary QR storage failure | Terminal resource errors additionally include `no_url_returned`, `extraction_failed`, `extraction_timeout`, `scan_failed`, `qr_expired`, and `order_timeout` in the resource `error` field. ## Recommended gc-plus recovery flow ```text register -> GET /balance and check extraction_fee -> POST /extractions with a stable client_request_id and callback -> on ambiguous POST result, GET /extractions/by-client-request/{client_request_id} -> wait for SUCCESS/url_ready and open long_url or copy_paste -> capture the displayed QR -> GET /balance and check order_fee -> POST /orders with a new stable client_request_id and task_no -> on ambiguous POST result, GET /orders/by-client-request/{client_request_id} -> wait for COMPLETED/scan_completed -> refresh the local page, close the window, and mark gc-plus successful ``` Prefer callbacks for normal progress and recovery GETs after missed callbacks or local restarts. GET requests are safe to retry. POST retries are safe only when the same `client_request_id` and original content are reused.