@ait-kit/api-core
Runtime-neutral typed adapters for Apps in Toss server APIs and mTLS transports.
npm install @ait-kit/api-core
import { createAppsInTossApiRpcFromOptions } from "@ait-kit/api-core";
const tossApi = createAppsInTossApiRpcFromOptions({
mode: "forward",
mtlsClient
});
IAP order verification contract
iapOrderStatus separates what the provider attested from what the caller
asked. Grant decisions must gate on verified, never on ok:
ok: true— theget-order-statusquery succeeded and the provider returned a well-formed payload. A payload missing the spec-requiredorderIdorstatusfails witherror: "INVALID_RESPONSE"instead of being patched up from the request. Evidence fields must be real strings: anorderId,status, or present optional field (sku,statusDeterminedAt,reason) arriving as an array, object, number, or boolean isINVALID_RESPONSEtoo — coerced values are never used as verification evidence. Failure envelopes (FAIL,ERROR,NETWORK_ERROR,TIMEOUT) yield no evidence even when a contradictorysuccesspayload rides along, and unrecognizedresultTypevalues areINVALID_RESPONSE, never optimistic successes.verified: true— the provider-attested status isPAYMENT_COMPLETEDorPURCHASEDand the provider-returnedorderIdmatches the request. ResponseorderId/skucome exclusively from the provider payload; request values are expectations, never evidence.verificationCode— required on every unverified success:PAYMENT_INCOMPLETE(retryable, the pending re-query flow is preserved),PAYMENT_FAILED,PAYMENT_REFUNDED,MINIAPP_MISMATCH,ORDER_NOT_FOUND(retryable),ORDER_ID_MISMATCH,UNKNOWN_STATUS,PROVIDER_STATUS_ERROR, orSTUB_EVIDENCE.skuCheck— present when the caller passes an expectedsku:MATCHED,MISMATCHED, orNOT_PROVIDED. The providerskuis optional in the official API, so a missing SKU never flipsverified; there is no supplementary lookup endpoint, so incomplete evidence on a payable status can only be re-queried through this same call.stub: true— marks synthetic stub-mode output. Stub responses fabricate aPAYMENT_COMPLETEDorder for development but are neververified(verificationCode: "STUB_EVIDENCE"), so a deployment that accidentally runs in stub mode cannot grant purchases on fabricated data. Development flows that want to exercise grant logic must explicitly opt in by checkingstub: true.
Grant gating example — verified alone confirms a payable order for the
requested order ID; when your catalog expects a specific product, also
require the SKU comparison before crediting:
const result = await tossApi.iapOrderStatus({ orderId, sku: "SKU_100_COINS" });
if (!result.ok) {
// The lookup itself failed (transport, provider error, INVALID_RESPONSE):
// no grant decision can be made from this response.
throw new Error(`order status lookup failed: ${result.failureReason ?? result.error}`);
}
// verified: provider attests a payable status for THIS order.
// skuCheck: the product evidence matches what the caller expects.
const grantable =
result.verified === true && !result.stub && result.skuCheck?.status === "MATCHED";
// NOT_PROVIDED means the provider omitted SKU evidence: the order is paid,
// but which product it bought is unconfirmed — decide explicitly.
Message recipients
normalizeMessageRecipient turns legacy { userKey, tossUserKey, anonKey }
inputs into a single MessageRecipient ({ kind: "user", userKey } or
{ kind: "anonymous", anonKey }). Exactly one identifier must be supplied:
requests with none, more than one, values of the wrong type, or empty
strings are rejected (INVALID_MESSAGE_RECIPIENT for single sends,
INVALID_CONTEXT_RECIPIENT for bulk contextList items) — identically in
stub and forward mode. Each API converts the recipient itself:
- Single (and test) sends carry the recipient in request headers:
x-toss-user-keyfor user recipients,x-anon-keyfor anonymous ones. (Earlier releases emittedx-user-key, which the official API does not define; consumer proxy code that rewrote the header can now be removed.) - Bulk sends carry recipients in
contextListbody fields (userKey/anonKey), never in headers.
Smart message send results
smartMessageSend / smartMessageBulkSend only report success from
send-result evidence the provider actually returned. providerStatus
carries three meanings:
"SENT"— the send result contained real counts (an explicitmsgCountof0included) or channel counts confirming delivery."FAILED"— a definite failure: explicit failure entries on a zero-send result, aFAIL/ERRORenvelope, or a 4xx rejection.providerErrorCodemirrors a code the provider returned; channel results, failure reasons, andcontentIdsare preserved for partial successes."UNKNOWN"— the outcome could not be determined: an evidence-free body (empty object, HTML error page,SUCCESSenvelope without a send result), wrong-typed counts, an unrecognized or outcome-ambiguousresultType(NETWORK_ERROR,TIMEOUT), or a 5xx response. These carry the internalerror: "INVALID_RESPONSE"marker (parse/validation) and keepproviderRequestIdcorrelation, but never a fabricatedsentAt. The message may or may not have been delivered: resolve the state out of band — do not assert non-delivery and do not auto-resend on this signal alone.
The same evidence rule applies to single and bulk sends, and to
already-normalized responses (only this module's own SENT/FAILED
vocabulary round-trips; anything else is UNKNOWN).
Anonymous keys and verification
verifyAnonKey calls POST /api-partner/v1/apps-in-toss/users/anon-key/verify
with the key in the x-anon-key header. Results are tri-state:
{ ok: true, valid: true }/{ ok: true, valid: false }— definitive provider verdicts.{ ok: false, ... }(ProviderFailure) — no verdict was obtained (transport failure, non-2xx, FAIL envelope such as errorCode4010, malformed response). Never translate this intovalid: false.
Prefixes: the SDK's getAnonymousKey() resolves to { type: "HASH", hash } and the hash value is the key. Server APIs expect exactly that
value. Applications that decorate stored identifiers with their own prefix
(for example anon: or anonymous-) must strip their own prefix before
calling; the kit transmits keys byte-for-byte and never adds or removes
prefixes in either direction.
Recoverable promotion grants
promotionRewardGrant (legacy) chains key issuance, execution, and a result
lookup in one call. The explicit three-step flow decouples them so your
database sits between the phases:
// The recipient identity the grant is for — exactly one of userKey /
// tossUserKey / anonKey, the same value you will execute with. Strip your
// own storage prefix first; the kit transmits identifiers byte-for-byte.
const recipientKey = "anon:stored-hash"; // your app-prefixed storage form
const recipientAnonKey = recipientKey.replace(/^anon:/, "");
// 1. Issue a transaction key for that recipient. Prepare carries the
// recipient as the single identity header and sends no request body;
// a call with a missing, duplicated, or malformed recipient is rejected
// before anything is dispatched.
const prepared = await tossApi.promotionPrepareReward({ anonKey: recipientAnonKey });
if (!prepared.ok) throw new Error(prepared.failureReason);
// 2. Persist the key BEFORE executing. Bind it to the owner, the recipient
// identifier you prepared with, the promotion code, the amount, and
// your own request id — see the storage guidance below.
await db.promotionGrants.insert({
providerTransactionKey: prepared.providerTransactionKey,
ownerId: session.userId, // who initiated the grant
recipientKey, // exactly what you prepared with
promotionCode: "WELCOME_EVENT",
amount: 1000,
requestId: request.id, // your idempotency/tracing id
stage: "PREPARED"
});
// 3. Execute the grant with the stored key and the SAME recipient.
const executed = await tossApi.promotionExecuteReward({
providerTransactionKey: prepared.providerTransactionKey,
promotionCode: "WELCOME_EVENT",
amount: 1000,
anonKey: recipientAnonKey // or userKey / tossUserKey
});
// 4. Confirm the outcome. If step 3 crashed or returned UNKNOWN, re-run
// only this step — status issues no keys and executes no grants.
const status = await tossApi.promotionRewardStatus({
providerTransactionKey: prepared.providerTransactionKey,
promotionCode: "WELCOME_EVENT",
anonKey: recipientAnonKey
});
// status.status: "GRANTED" | "PENDING" | "FAILED" | "NOT_FOUND" | "UNKNOWN"
Outcome semantics
SUBMITTED(execute) means the provider accepted the request — confirm with the status step; it is not the final grant state.UNKNOWNmeans no verdict was obtained: transport failures, 5xx, or unparseable responses. The transaction key is always preserved so you can resolve the outcome with a status query. Never treatUNKNOWNas a definite failure, and do not automatically re-execute: the provider rejects same-key re-execution with error 4113 ("already granted/ retracted"), but the official contract does not document idempotency for repeated execution.- Explicit failures (execute
ok: false, status"FAILED") come only from a provider verdict — FAIL envelopes with error codes, 4xx, or the documentedFAILEDstatus (the provider rolls back the used budget). "NOT_FOUND"is the documented error 4111: no grant record exists for the key (never executed, or the record is gone).- The official API supplies no grant timestamp and no documented key
expiry. Status responses report
checkedAt(when you observed the status) and never fabricate agrantedAtfrom your request time. - Passing a request identifier of your own does not make an external grant idempotent — treat it as tracing metadata only.
Consumer responsibilities
The provider only deduplicates by transaction key. Your application owns:
- Storage: save the key bound to the owner, recipient identifier, promotion code, amount, and your request id before executing, so a lost execute response can always be resolved against what you intended to do.
- Ownership checks: verify the caller owns the stored grant before executing or polling it.
- Concurrency control: prevent two executes for the same key from racing (the legacy 4113 rejection is the provider's backstop, not a contract).
- Ledger updates: credit balances only on
GRANTED, and designUNKNOWNhandling (retry the status query with backoff) rather than guessing.
The legacy promotionRewardGrant keeps its original behavior, including
treating a caller-supplied providerTransactionKey as a result lookup
only — passing an existing key never triggers an execute through that API.
See the AIT Kit repository for supported APIs, Cloudflare bindings, examples, and release notes.
Recipient privacy and numeric IDs
Promotion prepare/execute/status suppress failureReason text when it contains
the submitted recipient (including short IDs and trimmed header spellings).
Provider error codes, transaction keys and outcome semantics are preserved.
This protects returned failure text; raw transport logs and the generic mTLS
relay remain the caller's responsibility.
Numeric userKey and tossUserKey must be safe integers. Fractional and unsafe
numbers are rejected before dispatch, including in single/bulk Smart Message
requests. Use an exact string for large IDs; converting an already-rounded Number
to a string cannot recover the original identifier.
Anonymous user authentication (SDK 3.6+)
exchangeAnonAuthCode({ code }, { mode: "forward", mtlsClient, signal })
exchanges a fresh SDK-issued code for { ok: true, anonKey }. Use an
application-controlled abort signal for the request deadline. Exchange immediately;
codes are single-use and expire after five minutes. The helper makes one request,
never retries automatically, and never returns fabricated identities in stub mode.
Failures contain fixed error categories rather than provider payloads or secrets.
Cancellation returns ABORTED, distinct from UPSTREAM_UNAVAILABLE; neither
result includes a transport exception or the caller-supplied abort reason.
verifyAnonKey checks that a key exists; it does not authenticate the caller.
Authenticate with code exchange before issuing your own session. Order ownership,
per-account grant routing, replay prevention, and a durable purchase ledger remain
the application's responsibility. This helper does not itself authorize purchases.
Official contract: https://developers-apps-in-toss.toss.im/api/user-key.md