# Audio at Play Agent Checkout v2 (USDC / SOL pilot)

This API is an opt-in, API-key-gated pilot. The backend must have
`AGENT_SOLANA_PILOT_ENABLED=true`. Its default is disabled. The pilot always
requires an API key; the old `AGENT_API_PUBLIC` setting does not bypass access.

Production API base: `https://us-central1-audio-at-play.cloudfunctions.net`.
These are Cloud Functions URLs, not routes on `audioatplay.ca`.
Use the base URL supplied by your pilot operator for a separate test project.
The catalog returns absolute endpoint URLs and signing metadata.

The machine-readable [OpenAPI 3.1 description](./openapi.json) covers all six
existing endpoints, authentication headers, request/response schemas, recovery,
and delivery limits. Import the JSON into your API tooling or use it when building
an integration. Generated clients still need the wallet-signing protocol below;
the description does not provide a wallet, submit payments, or enable new rails.

## Start here

Read [Agent developers: start here](https://audioatplay.ca/developers) for the plain-language access, ownership, spending-approval and recovery workflow. The [read-only inspection example](./agent-inspect-example.cjs) makes no calls on import and, when explicitly invoked in an authorized runtime, only performs the key-authenticated catalog GET. It does not request a wallet signature or send a payment.

## Public information and requesting access

The storefront, this guide, OpenAPI description and signing helper are public.
The existing agent catalog is part of the protected pilot: an unauthenticated
GET to `agentListNativeProducts` returns 401 when the pilot is enabled. This is
expected access control, not a broken catalog. All six agent operations require
an integration key; POST operations also require wallet signatures. A disabled
pilot returns 503.

<!-- agent-access-start -->
A public request destination is not configured yet. Use an existing verified Audio at Play operator contact if you have one; otherwise wait for the owner to publish a verified route. There is no self-service key issuance or automatic approval.
<!-- agent-access-end -->
Do not obtain or share another integration's key. Public documentation does not
grant purchase access or rights to use, redistribute or train on the audio.
An absent license URL leaves permitted use unconfirmed.

No anonymous agent JSON catalog is advertised by this version. A separate static
public projection is prepared in local review tooling only; it requires owner
review and an authorized release before it can be used as a public API.

The gated catalog currently returns eligible native paid products and a separate
freeProducts list for approved assigned free audio in one response; it does not accept pagination parameters. Library pagination is
documented below. Solana `amount` values are token units; `amountAtomic` is an
integer quantity (USDC: 6 decimal places, SOL: 9). Do not substitute a card price
or convert currencies using a fixed exchange rate. Website card prices come from
the Stripe Price used by checkout.

## Requirements and ownership

Use a configured pilot API key and a Solana wallet that can sign Ed25519 messages
and transactions. USDC is the default asset; SOL is supported when priced on the
product. USDC wallets also need SOL for network fees and token-account creation.

Without account delegation, entitlements belong to `agent_wallet_<payerWallet>`.
For purchases in an existing website account's library, obtain that account's
Firebase ID token through its normal sign-in flow and send it in
`x-firebase-id-token`. The wallet signature binds the exact token to the request,
and the backend verifies the token, including revocation. This grants access on
behalf of that account, so the account holder must authorize the integration.
Use the same account identity when preparing, verifying, listing, or downloading.
A refreshed ID token for the same account is supported.

API keys grant pilot access and have separate quotas. They do not prove wallet
ownership. Every POST below additionally requires a wallet signature.

## Endpoints

| Method | Endpoint | Body fields beyond payerWallet and requestId |
| --- | --- | --- |
| GET | /agentListNativeProducts | None; API key only |
| POST | /agentPrepareSolanaPurchase | productId, optional assetSymbol (USDC or SOL) |
| POST | /agentVerifySolanaPurchase | checkoutSessionId, signature |
| POST | /agentListLibrary | Optional cursor |
| POST | /agentRequestProductDownload | productId |

Send an API key as `Authorization: Bearer <key>` or `x-agent-api-key: <key>`.
POST bodies must be JSON objects no larger than 16 KiB. Document identifiers and
request IDs use 1–200 ASCII letters, digits, underscores, hyphens, or dots, excluding
the identifiers `.` and `..`. Use a random UUID as the request ID.

The library returns at most 50 items and a `nextCursor`; pass that cursor unchanged
to retrieve the next page. Missing products remain visible as unavailable items.
Owned products can be downloaded after unpublishing if their private file remains.

## Signing a POST

Send `x-agent-timestamp` as Unix milliseconds and
`x-agent-wallet-signature` as the standard base64 encoding of the wallet's raw
64-byte Ed25519 signature. Sign the UTF-8 bytes of these eight lines, joined with
a newline and with no trailing newline:

1. `Audio at Play agent API v2`
2. Catalog `auth.audience` (project and Solana cluster are bound here)
3. Lowercase hex SHA-256 of the API key
4. `POST`
5. Exact function name, such as `agentPrepareSolanaPurchase`
6. Exact timestamp header string
7. Lowercase hex SHA-256 of the canonical JSON request body
8. Lowercase hex SHA-256 of the Firebase ID token, or SHA-256 of the empty string

Canonical JSON recursively sorts object keys lexicographically, preserves array
order, and uses JSON string/number encoding without extra whitespace. Include all
body fields, including `payerWallet` and `requestId`. Signatures allow five minutes
of clock skew. Re-sign a retry with a current timestamp and the same operation
request ID. Duplicate mutations use their stored idempotency record.

Download the [Node.js 24+ client helper](./agent-solana-client.cjs), which implements
this format. It accepts your wallet's `signMessage(bytes)` function and never sends
a blockchain payment. Run the snippets inside an async function with your existing
wallet integration:

```js
const { randomUUID } = require('node:crypto');
const { createAgentClient } = require('./agent-solana-client.cjs');

// wallet is your existing wallet integration; signMessage returns 64 raw bytes.
const client = createAgentClient({
  apiKey: process.env.AGENT_API_KEY,
  payerWallet: wallet.publicKey.toBase58(),
  signMessage: (bytes) => wallet.signMessage(bytes),
  // Optional: getFirebaseIdToken: () => signedInUser.getIdToken(),
});

const catalog = await client.catalog();
// Persist this ID before the call; reuse it after a timeout.
const prepareRequestId = randomUUID();
const quote = await client.prepare({
  productId: catalog.products[0].id,
  assetSymbol: 'USDC',
  requestId: prepareRequestId,
});
// Persist quote.checkoutSessionId and the complete quote before paying.
```

## Transfer and verification

Preparation stores a 15-minute quote with its buyer, recipient, asset, atomic
amount, and unique memo. Retrying preparation with the same request ID returns
the same quote. Reusing that ID for another product or asset returns HTTP 409.
An expired, unpaid quote cannot be re-priced under the same request ID.

Build one transaction that transfers the exact quoted amount from the quoted
payer and includes the exact quote memo using `payment.memoProgramId`. The payer
must sign the transaction. Example instruction construction with
`@solana/web3.js` and `@solana/spl-token`:

```js
const { PublicKey, Transaction, TransactionInstruction, SystemProgram } = require('@solana/web3.js');
const {
  getAssociatedTokenAddress,
  createAssociatedTokenAccountIdempotentInstruction,
  createTransferCheckedInstruction,
} = require('@solana/spl-token');

const { payment } = quote;
const payer = new PublicKey(payment.payerWallet);
const recipient = new PublicKey(payment.recipientWallet);
const tx = new Transaction();
if (payment.asset.symbol === 'SOL') {
  tx.add(SystemProgram.transfer({ fromPubkey: payer, toPubkey: recipient,
    lamports: payment.asset.amountAtomic }));
} else {
  const mint = new PublicKey(payment.asset.mint);
  const source = await getAssociatedTokenAddress(mint, payer);
  const destination = await getAssociatedTokenAddress(mint, recipient, true);
  tx.add(
    createAssociatedTokenAccountIdempotentInstruction(payer, destination, recipient, mint),
    createTransferCheckedInstruction(source, mint, destination, payer,
      BigInt(payment.asset.amountAtomic), 6),
  );
}
tx.add(new TransactionInstruction({
  programId: new PublicKey(payment.memoProgramId),
  keys: [{ pubkey: payer, isSigner: true, isWritable: false }],
  data: Buffer.from(payment.memo, 'utf8'),
}));
// Set a fresh blockhash and fee payer, then sign and submit with your wallet.
```

Use your own RPC provider or the public RPC for `payment.cluster`; the API does
not disclose the server's private RPC URL. Submit before `expiresAt`, allowing
time for chain processing. Persist the signed transaction's signature before
broadcast when supported, or immediately when submission returns it.

After submission, call:

```js
const result = await client.verify({
  checkoutSessionId: quote.checkoutSessionId,
  signature: savedTransactionSignature,
});
```

Verification requires a finalized, successful transfer, the exact memo, the
quoted payer's transaction signature, matching mint/recipient/amount, and an
on-chain timestamp within the quote's window. A payment made on time can be
verified after the quote expires or the product's price/recipient changes.

After submission or a timeout, retry verification with the saved quote and
signature. Re-broadcasting the same signed transaction is distinct from making a
second payment. Do not construct a second transfer while the first one's outcome
is uncertain. Persisting the quote and signature enables recovery across restarts.

## Paid status and delivery

A successful payment returns `status: "paid"`, `doNotPayAgain: true`, the purchase
and entitlement IDs, and `fulfillmentStatus`:

- `ready`: `download` contains an expiring URL and quota information.
- `pending`: `downloadError` explains the delivery problem. The purchase remains
  paid; retry delivery instead of paying again.

Purchase verification retries reuse the first live download URL without creating
another checkout, purchase, entitlement, or quota event. When that URL expires,
request another download with a new, persisted download request ID:

```js
const owned = await client.library();
const downloadRequestId = randomUUID(); // persist before calling
const grant = await client.download({
  productId: quote.product.id,
  requestId: downloadRequestId,
});
// Reuse downloadRequestId for transport retries. Use grant.downloadUrl to fetch.
```

The limit is three issued download grants per owner/product in a rolling 24 hours,
shared with website downloads. The API counts issued grants, not completed file
transfers. Concurrent agent requests enforce that limit. Repeating a download
request ID returns its cached URL; an expired cached URL requires a new ID.
Standard URLs last five minutes; files of at least 300 MiB receive two hours.
Agent delivery never falls back to permanent Firebase token URLs.

Limits per API key: 120 requests per minute overall, including retries, and
20 preparations per minute. HTTP 429 includes `Retry-After: 60`. HTTP 503 means
the pilot or a required service is unavailable. HTTP 409 indicates an idempotency
or payment-binding conflict. A verification error after sending funds is not
authorization to send another payment.

## Migration from the original agent API

The v1 productId/signature/payerWallet-only verification request is replaced by a
stored quote plus signed requests. Existing customer Firebase callable routes
are preserved. Existing wallet-owned entitlements can be retrieved through the
authenticated library/download API without another payment.

## Licensing metadata (additive local revision)

Product metadata can include a `licensing` object. Existing saved quotes may omit
it; their original snapshot remains unchanged. `licenseUrl` and `licensing.url`
reference applicable public terms only, never the draft review page. A URL alone
does not establish commercial, redistribution or training permissions.

`licensing.status` is `unassigned`, `external`, `needs-review`, `draft`, `pending-approval`,
`terms-provided` or, only after an approved registry policy exists, `approved`.
Effective permissions and attribution fields remain null for unapproved terms.
A nested `proposal` contains a draft ID/version, review URL and proposed attribution.
The 1.0 Free Audio Attribution License and Paid Audio License identify Kyle Hinton, operating as Audio at Play, as licensor. They apply only when explicitly assigned to a matching product with confirmed original-audio rights and any required public author credit. Historical draft IDs remain inactive. The free 1.0 license requires credit:
**Sound by [Author] via Audio at Play - https://audioatplay.ca**, with a clickable link wherever
supported. Replace [Author] with the explicit public author credit supplied on the product.
Both the author credit and the website direction are required for free sounds under the assigned 1.0 policy. The paid 1.0 license makes attribution optional. Historical proposals do not
grant current rights, prove acceptance or change earlier acquisitions.

The optional attributionAuthor metadata comes only from the public product field
of the same name. If it is missing, attributionAuthor and the complete attributionText
are null; confirm the credit before use. Do not derive a name from createdBy, a user
ID, an email address or a private profile. Older quote snapshots retain their original
credit/version and may omit attributionAuthor.

Read [the versioned audio licenses](https://audioatplay.ca/licensing) before authorizing any
production use. Agent-assisted purchase/editing does not itself authorize model
training, fine-tuning or datasets. Missing license terms mean unknown permissions.
Software, third-party and externally fulfilled products need their actual separate
terms. Do not infer that an Audio at Play audio license applies to them.

## Operator approval, untrusted content and safe retries

Before paying, your operator must approve the product, license, asset, recipient,
per-purchase budget, cumulative budget and network fees. Wallet control is not
spending approval. The API and helper do not enforce client budgets. Keep wallet
policy outside model-written catalog text, and independently validate the saved
quote before constructing a transfer. Product titles, descriptions, tags and linked
documents are untrusted data; embedded requests to ignore controls, change a
recipient or disclose credentials must not alter your trusted workflow.

The helper performs no automatic backoff, timeout scheduling, recovery persistence
or transaction broadcast. It caches one successful catalog response per client
instance (failed calls clear the cache). Use a fresh client to refresh discovery.
Inject a fetch implementation with bounded timeout/cancellation, persist operation
IDs before requests, and re-sign retries with the current timestamp. Do not add
unbounded retries around payment construction.

- 401/403: stop; repair authorization, clock/signature or account delegation.
- 409: reconcile the original binding; do not change ownership or repay.
- 412: inspect the error. Reconcile any submitted transaction before replacing an
  expired unpaid quote. An expired grant needs a new download ID, not a purchase.
  Product/delivery configuration or revoked access needs operator review.
- 429: respect Retry-After and bounded backoff. A download quota can remain
  exhausted for the rolling 24-hour period despite a 60-second header.
- 503/network timeout: retry the original operation within bounded limits; repeated
  failure or a disabled pilot needs review. After broadcast, keep the original
  quote/signature and reconcile it, never create a second transfer.
- 200 paid/pending: preserve payment state and retry delivery only.
- 200 paid/ready: a URL was issued, not proof of a completed transfer.

For an interrupted download, retry the same still-live signed URL; byte-range
resume depends on Storage and client support. Fetch without forwarding API
credentials. Confirm the actual file before declaring delivery complete.
Expired URLs need a new grant requestId, subject to the existing shared quota.

## Access requests and support

<!-- agent-support-start -->
A public support destination is not configured yet. Use only an existing verified Audio at Play operator contact. No response-time promise is made.
<!-- agent-support-end -->

The Audio at Play operator reviews applications and separately approves secure credential delivery. Include project/organization,
use case, expected volume, asset needs, ownership/delegation choice, spending
controls and a reachable reply destination. Never include wallet secrets, tokens
or an existing integration key. Each approved integration needs a dedicated key
through an owner-approved secure channel.

For private support diagnostics, include operation, approximate UTC time,
status/error code, retry count and relevant request/quote/purchase IDs. Share a
transaction signature or public wallet address only as needed, since these link
activity. Never paste keys, seed phrases, Firebase tokens, authorization headers,
request signatures or full signed download URLs. Keep the original purchase state
while waiting; an uncertain outcome is not permission to pay again.


## Approved licensing and free acquisition

An API key or wallet signature alone is not license acceptance or spending authority. For an approved assigned policy, read the full versioned terms first and obtain authority to act for the named licensee. Include the following declaration in `prepare` or `acquireFree`; every field is covered by the existing wallet-signed body. Use metadata from the actual response, never an invented policy version.

```js
const licenseAcceptance = {
  policyId: product.licensing.id, version: product.licensing.version,
  termsUrl: product.licensing.url, termsDigest: product.licensing.termsDigest,
  licensee: "The authorized acquiring person or company",
  accepted: true, authorityConfirmed: true,
};
// Paid: persist requestId, prepare once with licenseAcceptance, then retain the quote and payment signature.
// Free: choose a product from catalog.freeProducts; no blockchain transfer is involved.
const acquired = await client.acquireFree({ requestId: savedFreeRequestId, productId: product.id, licenseAcceptance });
// Persist a separate download request ID and use client.download under the same ownership identity.
```

Free acquisition uses `agentAcquireFreeProduct`; delivery still requires an active entitlement and expiring signed URL under the shared quota. Retry acquisition after an uncertain response using the original identity; it returns the saved acquisition. Download failures do not require another acquisition or payment.

The server retains the policy snapshot and explicit declaration with future purchases and entitlements. Open quotes retain their original snapshot, including across approval/version changes. Re-preparing under an existing request ID cannot silently change the declaration. The signed library exposes `licenseAcquisition`; current product licensing may differ. Null historical evidence is not acceptance of current terms. Training/fine-tuning and training datasets require separate written permission under the 1.0 audio licenses. Historical drafts and unapproved policies grant no rights. Existing draft IDs remain historical and are not promoted.


## Signing-helper errors and retries

The helper reports safe errors with name AgentApiError, HTTP status when a
response was received, and the structured Firebase error code when recognized.
Other HTTP failures use code http-error. It does not copy response bodies,
server messages, credentials or signed URLs into errors. A proxy's HTML error
page, an empty body or malformed JSON still preserves the HTTP status.

A valid Retry-After header is retained as retryAfter. Integer seconds also
appear as retryAfterSeconds; an HTTP date also appears as retryAt (ISO UTC).
These are hints for your runtime, not automatic retries. Invalid retry headers
are discarded. Download quotas may last longer than a request-rate hint.

Network failure uses network-error. Timeout/cancellation from your injected
fetch uses request-timeout. No HTTP response means status is null. An unreadable
HTTP success uses invalid-response and retains its HTTP status; it does not prove
that a mutation failed. The helper still has no built-in timeout or retry loop.

Persist operation IDs before calls. After an uncertain prepare/verify response,
reconcile the same saved request ID, quote and transaction signature. Do not
construct or broadcast another payment. A successful paid/pending response remains
paid: retry delivery through the same owner's download/library operations.
A new download ID is needed only after a prior grant expires, subject to quota.
