IAP Gateway — iOS
IAP Gateway is designed for co-published games that run their own game server and want full control over item delivery. Alogame only handles receipt verification with Apple — your game server decides when and how to grant items to the player.
APIs on this page:
POST /iap/gateway/verify— verify a receipt after a purchase- Refund callback — Alogame calls your server when Apple refunds a purchase
POST /iap/gateway/lookup-receipt— look up an Apple Order ID directly (support tool)
| Alogame SDK IAP (standard) | IAP Gateway | |
|---|---|---|
| iOS client | OEGPayment.purchaseAndVerify() | StoreKit directly |
| Receipt verification | Alogame Backend calls Apple | Alogame Backend (triggered by your game server) |
| Item delivery | Alogame calls webhook on your server | Your game server handles it after receiving the result |
| Best for | Games using the full Alogame SDK | Co-pub games with their own backend that want full control |
Before you start
Contact Alogame to receive:
client_idandclient_secret— used by your game server to authenticate with Alogame IAP Gateway- Endpoints:
- Development / Sandbox:
https://api-sdk.dev.alogame.vn/iap/gateway/verify - Production:
https://api-sdk.alogame.vn/iap/gateway/verify
- Development / Sandbox:
client_secret is issued only once. Save it securely immediately — it cannot be retrieved again.
How it works
Step 1 — iOS app retrieves the receipt
StoreKit 2 (recommended — iOS 15+)
import StoreKit
func purchase(productId: String) async throws -> String {
guard let product = try await Product.products(for: [productId]).first else {
throw PurchaseError.productNotFound
}
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
let receipt = transaction.jwsRepresentation
await transaction.finish()
return receipt
case .unverified:
throw PurchaseError.unverified
}
case .userCancelled:
throw PurchaseError.cancelled
case .pending:
throw PurchaseError.pending
@unknown default:
throw PurchaseError.unknown
}
}
jwsRepresentation returns a JWS token (three dot-separated parts). Send this string in the receipt field when calling Alogame Gateway — Alogame will detect SK2 automatically and verify it with Apple.
StoreKit 1 (SK1 — iOS 13+)
If your game is not yet on SK2, use appStoreReceiptURL:
import StoreKit
class PurchaseManager: NSObject, SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for tx in transactions where tx.transactionState == .purchased {
if let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) {
let receipt = receiptData.base64EncodedString()
sendToGameServer(receipt: receipt, productId: tx.payment.productIdentifier)
}
queue.finishTransaction(tx)
}
}
}
Whether you use SK1 or SK2, pass the receipt string in the receipt field when calling Alogame Gateway. Alogame automatically detects the type and verifies accordingly.
Step 2 — App sends receipt to your game server
This is an internal API between your iOS app and your game server — design it however you like. Example:
struct VerifyRequest: Encodable {
let receipt: String
let productId: String
let playerId: String
let serverId: String
}
func sendToGameServer(receipt: String, productId: String) async throws {
let body = VerifyRequest(
receipt: receipt,
productId: productId,
playerId: currentUser.id,
serverId: currentServer.id
)
var req = URLRequest(url: URL(string: "https://your-game-server.com/iap/verify")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
let (data, _) = try await URLSession.shared.data(for: req)
// Handle response from your game server
}
Step 3 — Game server calls Alogame IAP Gateway
Your game server uses the client_id and client_secret received from OEG.
Endpoint
POST https://api-sdk.dev.alogame.vn/iap/gateway/verify (development)
POST https://api-sdk.alogame.vn/iap/gateway/verify (production)
Request signing
Every request must include three authentication headers signed with HMAC-SHA256:
X-Client-ID: <client_id>
X-Timestamp: <unix_timestamp_seconds>
X-Signature: <hmac_hex>
Signature algorithm:
body_hash = SHA256(raw_request_body_bytes) # hex string
message = "{client_id}.{timestamp}.{body_hash}"
signature = HMAC-SHA256(client_secret, message) # hex string
Replay protection: Alogame rejects requests where |server_time − X-Timestamp| > 300 seconds (5-minute window).
Node.js example:
import { createHmac, createHash } from 'crypto';
const body = JSON.stringify({
receipt: '<jws_or_sk1_string>',
product_id: 'gems100',
os_id: 2, // 2 = iOS, 1 = Android
player_id: 'player-001',
character_id: 'char-001',
server_id: 'server-1',
game_version: '1.0.0',
});
const timestamp = String(Math.floor(Date.now() / 1000));
const bodyHash = createHash('sha256').update(body).digest('hex');
const message = `${CLIENT_ID}.${timestamp}.${bodyHash}`;
const signature = createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
const res = await fetch('https://api-sdk.alogame.vn/iap/gateway/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-ID': CLIENT_ID,
'X-Timestamp': timestamp,
'X-Signature': signature,
},
body,
});
Request body fields
| Field | Type | Required | Description |
|---|---|---|---|
receipt | string | Yes | JWS token (SK2) or base64 blob (SK1) from StoreKit |
product_id | string | Yes | Product ID (e.g. gems100) — Alogame cross-validates this against the Apple receipt; a mismatch returns 422 PRODUCT_MISMATCH |
os_id | integer | Yes | 2 = iOS, 1 = Android |
player_id | string | No | Player ID for your records |
character_id | string | No | Character ID |
server_id | string | No | Game server ID |
game_version | string | No | App version string |
Response
Success
{
"success": true,
"transaction_id": "2000000100000000",
"product_id": "gems100",
"receipt_info": {
"platform": "ios",
"environment": "Sandbox",
"quantity": 1,
"purchase_date_ms": 1716389123000,
"original_transaction_id": "2000000100000000",
"type": "Consumable",
"storefront": "VNM",
"price": 10000,
"currency": "VND",
"in_app_ownership_type": "PURCHASED"
}
}
receipt_info fields (iOS)
| Field | Type | Description |
|---|---|---|
platform | string | Always "ios" |
environment | string | "Sandbox" or "Production" |
quantity | integer | Number of units purchased |
purchase_date_ms | integer | Purchase timestamp in milliseconds (Unix epoch) |
original_transaction_id | string | Original transaction ID — stable across renewals / restores |
type | string | "Consumable", "Non-Consumable", "Auto-Renewable Subscription", or "Non-Renewing Subscription" |
storefront | string | ISO 3166-1 alpha-3 country code of the App Store storefront (e.g. "VNM") |
price | integer | Price in milliunits of currency (e.g. 10000 = 10 VND) |
currency | string | ISO 4217 currency code (e.g. "VND") |
in_app_ownership_type | string | "PURCHASED" or "FAMILY_SHARED" |
receipt_info is fully populated for SK2 JWS receipts. For SK1 base64 receipts, only environment, quantity, purchase_date_ms, and original_transaction_id are populated — type, storefront, price, currency, and in_app_ownership_type are null (SK1 does not expose these fields).
Error
{ "success": false, "error": "ERROR_CODE", "message": "human-readable detail" }
| Case | HTTP | error |
|---|---|---|
| Verification successful | 200 | — |
| Receipt already verified | 409 | DUPLICATE_TRANSACTION |
product_id in request doesn't match Apple receipt | 422 | PRODUCT_MISMATCH |
| Sandbox receipt sent to production (or vice-versa) | 412 | ENVIRONMENT_MISMATCH |
| Receipt rejected by Apple | 422 | VERIFICATION_FAILED |
| Apple server unreachable / unexpected error | 502 | VERIFIER_ERROR |
| Missing required fields | 400 | MISSING_FIELDS |
os_id value not 1 or 2 | 400 | INVALID_OS_ID |
| Invalid credentials or signature | 401 | UNAUTHORIZED |
success: true→ grant the item and calltransaction.finish().4xx→ request was wrong (bad fields, invalidos_id, auth failure). Fix the request; do not retry blindly.409 DUPLICATE_TRANSACTION→ Alogame already verified this receipt (common on retry after crash). Check if the item was already granted; do not re-grant.422 PRODUCT_MISMATCH→ theproduct_idyou sent does not match the product recorded in the Apple receipt. This is a sign of a receipt swap attack or a client bug — do not grant the item.412 ENVIRONMENT_MISMATCH→ the receipt is from the wrong environment (a Sandbox/TestFlight receipt hitting the production verifier, or vice-versa). Returned with its own HTTP status (distinct from422 VERIFICATION_FAILED) so you can branch on the status code alone and spot QA/test devices pointing at the wrong build. Do not grant the item; rebuild against the correct environment. If a game is approved to use Sandbox receipts in production, Alogame enablesallow_sandboxfor its client and these are accepted instead.422 VERIFICATION_FAILED→ Apple rejected the receipt. Do not grant the item.5xx→ upstream or server error. Safe to retry with exponential back-off.
Refunds
If Apple refunds a purchase after your game server already granted the item, Alogame notifies your server so you can claw it back. This is the reverse direction of Step 3 — same HMAC identity (client_id/client_secret), but Alogame is the sender this time.
Configure your callback URL
Set refund_callback_url for your client_id in the Alogame Console (IAP Gateway Clients panel), or ask Alogame to set it for you. If it's not set, refunds are still recorded on Alogame's side (visible to Alogame support) but never delivered to your server — you won't be notified automatically.
Without a configured callback, a refunded purchase's item stays granted on your side indefinitely unless you separately reconcile against Apple's own refund reports.
What Alogame sends
POST <your refund_callback_url>
Signed with the same HMAC-SHA256 scheme as Step 3, just computed by Alogame using your client_secret:
X-Client-ID: <your client_id>
X-Timestamp: <unix_timestamp_seconds>
X-Signature: <hmac_hex>
Verify it the same way the Node.js example in Step 3 signs a request — recompute the signature server-side and compare:
import { createHmac, createHash, timingSafeEqual } from 'crypto';
function verifyAlogameSignature(req) {
const { 'x-client-id': clientId, 'x-timestamp': timestamp, 'x-signature': signature } = req.headers;
if (clientId !== CLIENT_ID) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // same 5-min replay window
const bodyHash = createHash('sha256').update(req.rawBody).digest('hex');
const message = `${clientId}.${timestamp}.${bodyHash}`;
const expected = createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
return timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
}
Request body
{
"transaction_id": "2000000100000000",
"product_id": "gems100",
"revocation_date": 1736510400000,
"revocation_reason": 0,
"game_id": 42
}
| Field | Type | Description |
|---|---|---|
transaction_id | string | Same transaction_id your server received back in Step 3's success response |
product_id | string | The refunded product |
revocation_date | integer | null | Epoch ms — when Apple revoked the transaction |
revocation_reason | integer | null | Apple's revocation reason code (0 = other/unspecified, 1 = app issue) |
game_id | integer | Your game's id, for servers that host multiple games |
Your response
Return any 2xx to acknowledge. Alogame sends this once, on a best-effort basis — there is no automatic retry queue. If your endpoint is down or errors, Alogame support can retry manually from the Console once your server is back up, but nothing retries on its own, so treat a non-2xx/timeout on your side as something to alert on, not something Alogame will paper over.
Look up transaction_id in your own records and revoke whatever was granted — currency, items, entitlements. This callback fires regardless of whether the original purchase went through IAP Gateway's /verify or was recorded some other way, as long as it's the same game_id.
Looking up an order directly (support tool)
For when your own support team only has the human-readable Apple Order ID a player can see (e.g. "MTZ4Z8YFS7" from Report a Problem or order emails) — not a receipt blob, not a transaction_id. This asks Apple directly whether the order is genuine, then tells you whether Alogame's gateway ever received it via /verify.
Endpoint
POST https://api-sdk.dev.alogame.vn/iap/gateway/lookup-receipt (development)
POST https://api-sdk.alogame.vn/iap/gateway/lookup-receipt (production)
Same auth as Step 3 — HMAC-SHA256 with your client_id/client_secret, X-Client-ID/X-Timestamp/X-Signature headers. game_id is your gateway client's own game — there is no game_id field in the request body; you can only look up orders for your own game.
Request body
{ "order_id": "MTZ4Z8YFS7" }
Response
{
"order_id": "MTZ4Z8YFS7",
"game_id": 42,
"found": true,
"environment": "Production",
"transactions": [
{
"transaction_id": "2000000100000000",
"product_id": "gems100",
"gateway": {
"found": true,
"tx_mgmt_id": 991,
"current_state": "gateway_verified",
"last_error_code": null,
"last_error_message": null,
"recorded_for_this_game": true
}
}
]
}
If Apple doesn't recognize the order at all (found: false), or if it's genuine but was never received by your /verify call (gateway.found: false, reason: "NEVER_RECEIVED_BY_GATEWAY"), that tells you where to look next — either the order really doesn't exist, or your game client/server never actually called Alogame for it.
gateway.found: false is informational, not a verification failure — Apple has already confirmed the purchase is real by the time you see it. It only means the /verify call for this transaction never reached Alogame (or arrived malformed), which can be entirely normal (the game didn't route this purchase through the Gateway, or hit an issue before the call went out). This endpoint doesn't verify anything itself, so nothing here is ever a "failed" result.
gateway.reason (when gateway.found is false) | Meaning |
|---|---|
NEVER_RECEIVED_BY_GATEWAY | Apple confirms the purchase happened, but no /verify call for it was ever recorded — check whether your client/server actually sent it |
DECODE_FAILED | Apple returned this transaction but Alogame couldn't decode its signed data — treat as inconclusive, not as proof the order is fake |
Integration checklist
- Received
client_idandclient_secretfrom Alogame — stored securely server-side - iOS: app retrieves the correct receipt type (SK2
jwsRepresentationor SK1 base64) - iOS: calls
transaction.finish()only after the server confirms success - Game server: signs every request with HMAC-SHA256 (see Step 3)
- Game server: checks
successfield — not just HTTP status — before granting item - Game server: treats
409 DUPLICATE_TRANSACTIONas "already verified" — checks local state, does not re-grant - Game server: treats
422 VERIFICATION_FAILEDas "do not grant" — receipt was rejected by Apple - Game server: treats
412 ENVIRONMENT_MISMATCHas "wrong build/environment" — do not grant; flag the device as pointing at the wrong (Sandbox vs Production) environment - Game server: retries with exponential back-off on
5xx(VERIFIER_ERROR) - Game server: stores
transaction_idfor idempotent delivery — prevents double-granting - (Optional) Set
refund_callback_urlfor yourclient_idand implement the endpoint — verifies the same HMAC signature scheme, revokes the granted item on receipt - (Optional) Support team knows about
/iap/gateway/lookup-receiptfor investigating a player-reported Order ID directly