openapi: 3.0.3 info: title: PacketUP HUB reseller API version: 1.0.0 description: | Buy keys, gift cards and top-ups from your keyhub balance. ## Authentication Every request carries three headers: | Header | Value | |---|---| | `X-Api-Key` | your key id, e.g. `kh_…` | | `X-Timestamp` | current Unix time in seconds; requests older or newer than 5 minutes are rejected | | `X-Signature` | lowercase hex HMAC-SHA256 of the string below, keyed with your secret (`sk_…`) | The signed string is four lines joined with `\n`, and a fifth when the request has an `Idempotency-Key` (creating an order): ``` {X-Timestamp} {HTTP method, upper case} {path with query string, e.g. /v1/orders?limit=10} {lowercase hex SHA-256 of the raw request body; of an empty string for GET} {Idempotency-Key, only when the request has one} ``` ## Key scopes and addresses A key works only for what it was issued for. Without the scope a request gets 403 `forbidden`: | Scope | Endpoints | |---|---| | `catalog:read` | `GET /v1/products` | | `orders:write` | `POST /v1/orders` | | `orders:read` | `GET /v1/orders`, `GET /v1/orders/{id}` | | `balance:read` | `GET /v1/balance` | | `webhooks:write` | `/v1/webhook` and everything under it | A key can also be limited to IP addresses or ranges; a request from elsewhere gets 403 `ip_not_allowed`. Scopes and addresses are set when the key is created in the cabinet. ## Money Amounts are US dollars as decimal strings with 2 to 6 fractional digits, e.g. `"10.50"`. ## Orders Placing an order reserves its cost on your balance and returns at once with status `processing`. Items are bought one by one; poll `GET /v1/orders/{id}` until the status is final: `completed` (every item delivered), `partial` (some delivered, the rest refunded) or `failed` (nothing delivered, everything refunded). Send a unique `Idempotency-Key` with every new order. Retrying with the same key returns the original order instead of buying twice. ## Webhooks Instead of polling, register an HTTPS endpoint with `PUT /v1/webhook`. We POST a JSON `WebhookEvent` to it when something happens: | Event | When | |---|---| | `order.completed` | every item delivered | | `order.partially_completed` | some items delivered, the rest refunded | | `order.failed` | nothing delivered, everything refunded | | `order.review` | an item's purchase outcome is unknown; we are resolving it, funds stay reserved | | `balance.credited` | a deposit was credited | | `ping` | you asked for a test event | Events are thin: fetch the order with `GET /v1/orders/{id}` to get the codes. Each request has headers `X-Webhook-Id` (event id; the same on retries, use it to skip duplicates), `X-Webhook-Event`, `X-Webhook-Timestamp` (Unix seconds) and `X-Webhook-Signature`: lowercase hex HMAC-SHA256 of `{X-Webhook-Timestamp}\n{raw body}`, keyed with the webhook secret (`whsec_…`, returned once when the webhook is created). Verify it and reject old timestamps. For a day after the secret is changed in the cabinet, the header holds two signatures separated by a comma, made with the new and the old secret: accept the request if either matches. Answer with any 2xx within 10 seconds. Otherwise we retry 7 more times over about 22 hours (after 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h). Events may arrive out of order. ## Access The API opens once your deposits reach $50 in total; until then every request answers 403 `api_locked`. The cabinet's API keys page shows how much is left. Buying is free for the first day after you first open the cabinet. After that `POST /v1/orders` answers 402 `entry_fee_required` until the one-time entry fee is paid in the cabinet; everything else keeps working. ## Frozen account We may freeze an account while we check its funds. Until it is unfrozen, `POST /v1/orders` and `GET /v1/orders/{id}` answer 403 `company_frozen`; orders already placed wait and continue after it. The balance and the order list keep working, and transfers that arrive are credited. Contact support. ## Errors Errors use `application/problem+json` (RFC 9457) with a stable machine-readable `code`. servers: - url: https://packetuphub.example security: - signature: [] paths: /v1/balance: get: operationId: getBalance summary: Your balance responses: "200": description: Balance content: application/json: schema: $ref: "#/components/schemas/Balance" default: $ref: "#/components/responses/Problem" /v1/products: get: operationId: listProducts summary: Everything on sale, one item per denomination, with your prices responses: "200": description: Products content: application/json: schema: $ref: "#/components/schemas/ProductList" default: $ref: "#/components/responses/Problem" /v1/orders: post: operationId: createOrder summary: Place an order parameters: - name: Idempotency-Key in: header required: true description: >- Your unique id for this order, up to 100 characters. It is also your order number: the cabinet, CSV exports and webhooks show it as `reference`. schema: type: string minLength: 1 maxLength: 100 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateOrderRequest" responses: "201": description: Order accepted (or the original order, for a retried Idempotency-Key) content: application/json: schema: $ref: "#/components/schemas/Order" default: $ref: "#/components/responses/Problem" get: operationId: listOrders summary: Your orders, newest first, without codes parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 50 - name: before in: query description: Return orders placed before this order id (the `next_before` of the previous page). schema: type: string format: uuid responses: "200": description: A page of orders content: application/json: schema: $ref: "#/components/schemas/OrderList" default: $ref: "#/components/responses/Problem" /v1/orders/{id}: get: operationId: getOrder summary: An order with its delivered codes parameters: - name: id in: path required: true schema: type: string format: uuid responses: "200": description: Order content: application/json: schema: $ref: "#/components/schemas/Order" default: $ref: "#/components/responses/Problem" /v1/webhook: get: operationId: getWebhook summary: Your webhook settings responses: "200": description: Webhook content: application/json: schema: $ref: "#/components/schemas/Webhook" default: $ref: "#/components/responses/Problem" put: operationId: setWebhook summary: Create or update your webhook description: The signing secret is returned only when the webhook is created. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebhookSettings" responses: "200": description: Webhook content: application/json: schema: $ref: "#/components/schemas/Webhook" default: $ref: "#/components/responses/Problem" delete: operationId: deleteWebhook summary: Remove your webhook; queued events are dropped responses: "204": description: Removed default: $ref: "#/components/responses/Problem" /v1/webhook/ping: post: operationId: pingWebhook summary: Send a test `ping` event responses: "202": description: Queued default: $ref: "#/components/responses/Problem" /v1/webhook/deliveries: get: operationId: listWebhookDeliveries summary: Latest events sent to you, newest first parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 50 responses: "200": description: Deliveries content: application/json: schema: $ref: "#/components/schemas/WebhookDeliveryList" default: $ref: "#/components/responses/Problem" components: securitySchemes: signature: type: apiKey in: header name: X-Api-Key description: Key id plus X-Timestamp and X-Signature headers, see Authentication. responses: Problem: description: Error content: application/problem+json: schema: $ref: "#/components/schemas/Problem" schemas: Money: type: string pattern: '^\d+\.\d{2,6}$' example: "10.50" Balance: type: object required: [available, held, currency] properties: available: $ref: "#/components/schemas/Money" held: description: Reserved for orders that are still processing. allOf: - $ref: "#/components/schemas/Money" currency: type: string enum: [USD] Product: type: object required: [sku, name, product, denomination, category, region, kind, price, max_quantity, available] properties: sku: type: string example: pubg-660 name: type: string example: PUBG Mobile UC · 660 UC product: type: string example: PUBG Mobile UC denomination: type: string example: 660 UC category: type: string enum: [gift, keys, topup] description: The catalog section. region: type: string example: GLOBAL kind: type: string enum: [code, topup] description: "`code`: you receive a code. `topup`: the player account given as `uid` is credited." zone_label: type: string example: Zone ID description: >- Present for top-ups whose player account has a second part (a zone or a server): pass it as `zone` when ordering. price: description: >- Your price: the catalog price less your seller level's discount. Orders placed now are charged at it; it changes when your level does. allOf: - $ref: "#/components/schemas/Money" max_quantity: type: integer example: 10 description: The most units one order may hold (for top-ups, the supplier's limit of packages per purchase). available: type: boolean description: "`false` while no supplier can sell it; orders are refused then." ProductList: type: object required: [items] properties: items: type: array items: $ref: "#/components/schemas/Product" CreateOrderRequest: type: object required: [sku, quantity] properties: sku: type: string quantity: type: integer minimum: 1 maximum: 500 description: At most the product's `max_quantity`. uid: type: string description: Player account id; required for `topup` products, must be absent for `code` products. zone: type: string description: Second part of the player account, required when the product has a `zone_label`. OrderStatus: type: string enum: [processing, completed, partial, failed] OrderItem: type: object required: [id, status] properties: id: type: string format: uuid status: type: string enum: [processing, delivered, failed] description: "`failed` items are refunded." code: type: string description: Present once delivered, for `code` products. pin: type: string description: Present for gift cards that have a PIN. operation: type: string description: The supplier's number for the purchase, once delivered. For top-ups, the game's support finds the credit by it. Order: type: object required: [id, number, reference, sku, product_name, quantity, unit_price, total, status, created_at] properties: id: type: string format: uuid number: type: integer format: int64 example: 10482 description: The short number the cabinet shows. reference: type: string description: Your order number (the Idempotency-Key of the request); empty for orders from the cabinet without one. sku: type: string product_name: type: string quantity: type: integer unit_price: $ref: "#/components/schemas/Money" total: $ref: "#/components/schemas/Money" uid: type: string zone: type: string status: $ref: "#/components/schemas/OrderStatus" created_at: type: string format: date-time completed_at: type: string format: date-time items: description: In `GET /v1/orders/{id}` and in the `POST /v1/orders` answer; not in the order list. type: array items: $ref: "#/components/schemas/OrderItem" OrderList: type: object required: [items] properties: items: type: array items: $ref: "#/components/schemas/Order" next_before: type: string format: uuid description: Pass as `before` to get the next page; absent on the last page. WebhookEventType: type: string enum: - order.completed - order.partially_completed - order.failed - order.review - balance.credited WebhookSettings: type: object required: [url, events] properties: url: type: string example: https://shop.example.com/keyhub/webhook events: type: array minItems: 1 items: $ref: "#/components/schemas/WebhookEventType" Webhook: type: object required: [url, events, created_at, updated_at] properties: url: type: string events: type: array items: $ref: "#/components/schemas/WebhookEventType" secret: type: string description: Signing secret; present only in the response that created the webhook. created_at: type: string format: date-time updated_at: type: string format: date-time WebhookDelivery: type: object required: [id, type, status, attempts, created_at] properties: id: type: string format: uuid description: Event id, sent as X-Webhook-Id. type: type: string status: type: string enum: [pending, delivered, failed] attempts: type: integer last_status_code: type: integer description: HTTP status of the last attempt; absent if it got no response. last_error: type: string created_at: type: string format: date-time delivered_at: type: string format: date-time WebhookDeliveryList: type: object required: [items] properties: items: type: array items: $ref: "#/components/schemas/WebhookDelivery" WebhookEvent: description: Body we POST to your webhook. type: object required: [id, type, created_at, data] properties: id: type: string format: uuid type: type: string example: order.completed created_at: type: string format: date-time data: type: object description: | `order.*`: `{"order_id", "number", "reference", "status"}`; `order.review`: `{"order_id", "number", "reference", "item_id"}` (`reference` only when you gave one); `balance.credited`: `{"amount", "reference"}`; `ping`: `{}`. additionalProperties: true Problem: type: object required: [type, title, status, code] properties: type: type: string example: about:blank title: type: string example: Insufficient funds status: type: integer example: 402 code: type: string description: Stable machine-readable error code. enum: - invalid_request - unauthorized - forbidden - ip_not_allowed - company_frozen - api_locked - entry_fee_required - insufficient_funds - product_not_found - product_unavailable - uid_required - uid_not_allowed - zone_required - zone_not_allowed - quantity_over_limit - idempotency_conflict - webhook_not_configured - not_found - rate_limited - internal_error detail: type: string