Grant promo or adjustment credits
The credit_grants.create operation: POST /api/v1/wallets/{id}/grants in the REST API and the credit_grants_create tool in the MCP server.
/api/v1/wallets/{id}/grantsGrants credits to a wallet without a payment, with a reason:
promo: promotional credits, spent before any other and never refundable;adjustment: a goodwill credit, spent with purchased credits.
In the ledger the credits are a cost (promo_expense); unused promo and adjustment credits that expire reverse
it. expiresAt (optional) is when unused credits expire. A closed wallet takes no grants (wallet_closed); a
frozen one does.
Financial tier: a sign-in in the last 15 minutes for people, and an approval policy may require a second person
above an amount. Dry-run it first. Emits wallet.credited.
At a glance
- REST:
POST /api/v1/wallets/{id}/grants - MCP tool:
credit_grants_create - Operation ID:
credit_grants.create - Scope: Brand
- Permission:
wallets:write - Risk tier:
financial - Dry run: Supported (send
Ownrecord-Dry-Run: true) - Idempotency:
Idempotency-Keyrequired - Modes: Test and live
- Authentication: API key with the
wallets:writescope - Fresh sign-in: In the console, a sign-in within the last 15 minutes
- Since: M6
Request
Path parameters
| Field | Type | Description |
|---|---|---|
id | wal_… ID | The wallet (wal_…). |
Body
A JSON object (Content-Type: application/json):
| Field | Type | Required | Description |
|---|---|---|---|
source | one of promo, adjustment | Yes | promo: promotional credits (spent first, never refundable). adjustment: a goodwill credit spent with purchased credits. |
amount | object | Yes | The credits to grant, in the wallet's currency. |
amount.amount | string | Yes | Integer micros as a string (5000000 = 5.00). |
amount.currency | string | Yes | ISO 4217 currency code: the wallet's currency. |
reason | string | Yes | Why the credits are granted; shown on the grant, in the ledger and in the audit log. |
expiresAt | date-time | No | When unused credits expire (ISO 8601, in the future); they never expire without it. |
Response
201 Created with a JSON object:
| Field | Type | Description |
|---|---|---|
id | grant_… ID | |
object | "credit_grant" | |
brandId | brd_… ID | |
livemode | boolean | |
walletId | wal_… ID | |
source | one of purchase, subscription, promo, adjustment, refund_reversal | purchase, subscription, promo, adjustment or refund_reversal. |
amount | money | What was granted. |
remaining | money | What is left: not yet spent or expired (reserved credits count as left). |
priority | integer | Spend tier (§17.1): 1 promo, 2 subscription, 3 purchased and the rest. |
expiresAt | date-time or null | |
expired | boolean | expiresAt has passed: the credits can't be spent any more. |
reason | string or null | The operator's reason, for promo and adjustment grants. |
paymentId | string or null | |
invoiceId | string or null | |
createdAt | date-time |
Examples
€5 of welcome credits for a month
POST /api/v1/wallets/wal_01m4ty7w2a5qx7mhd9k8w6s0b1/grants
Authorization: Bearer or_test_…
Idempotency-Key: 3f1c9a52-7d0e-4b8f-9a61-0c2d5e8b7f14
Content-Type: application/json
{
"source": "promo",
"amount": {
"amount": "5000000",
"currency": "EUR"
},
"reason": "Welcome credits",
"expiresAt": "2026-11-01T00:00:00Z"
}
Response (201):
{
"id": "grant_01m4ty7w2a5qx7mhd9k8w6s0b3",
"object": "credit_grant",
"brandId": "brd_01m4tx5nj8emwbyf7y9vcf47x4",
"livemode": false,
"walletId": "wal_01m4ty7w2a5qx7mhd9k8w6s0b1",
"source": "promo",
"amount": {
"amount": "5000000",
"currency": "EUR"
},
"remaining": {
"amount": "5000000",
"currency": "EUR"
},
"priority": 1,
"expiresAt": "2026-11-01T00:00:00.000Z",
"expired": false,
"reason": "Welcome credits",
"paymentId": null,
"invoiceId": null,
"createdAt": "2026-10-16T10:15:00.000Z"
}
Errors
| Code | HTTP status | When | What to do |
|---|---|---|---|
validation_failed | 400 | The amount isn't positive, the reason is missing or too long, source isn't promo or adjustment, or expiresAt isn't in the future. | Fix the fields listed in details.issues and send the request again. |
resource_not_found | 404 | This brand has no such wallet in this mode (wallets belong to one brand and one mode). | Check the ID and that your credentials are scoped to the right organization, entity, brand and mode. |
wallet_currency_mismatch | 400 | The amount isn't in the wallet's currency. | Use the wallet for this currency. A customer has one wallet per currency (details.expected, details.actual). |
wallet_closed | 409 | The wallet is closed: reopen it before granting credits. | Reopen the wallet with wallets.update (status: active). A purchase of credits in its currency reopens it, too. |
idempotency_key_reused | 409 | The idempotency key was used before for another request. | Use a new Idempotency-Key for a different request, or resend the original request unchanged. |
version_conflict | 409 | The wallet changed at the same moment (another reservation, capture or grant); retry. | Fetch the object again and retry the change. |
approval_required | 202 | The organization's approval policy needs a second person for this; nothing changed yet. | Nothing ran yet. An approver reviews approval.summary and the dry-run result in the console (approval.approveUrl). Poll GET /approvals/{id} or watch for the approval.resolved event; sending the same request again with the same Idempotency-Key returns the same approval. |
approval_rejected | 409 | The same request was sent again after it was rejected. | Read the reason in details.approval.rejection. To try again, send a new request with a new Idempotency-Key. |
approval_expired | 409 | The same request was sent again after its approval request expired. | Send the request again with a new Idempotency-Key to create a new approval request. Requests expire after 72 hours. |
stale_approval | 409 | The approved request could no longer run as requested. | Check the current state (details.approval.failure says what failed), then send a new request with a new Idempotency-Key. |
fresh_auth_required | 403 | Your last sign-in is older than 15 minutes. | Sign in again, then repeat the action within 15 minutes. |
scope_required | 400 | The request names no brand (an org or entity key without Ownrecord-Brand). | Call the operation with credentials or a target at its level: an organization, a legal entity, a brand or the platform (details.required_scope). |
permission_denied | 403 | You can't change this brand's wallets (wallets:write: owners, admins and support). | Ask an organization owner or admin for a role or scope that allows this action. |
livemode_not_allowed | 403 | You have no live-mode access, so you can't use live wallets. | Use test mode, or credentials for the target's mode. Developers need live access granted by an organization owner or admin. |
It can also return these common errors: api_version_unsupported, invalid_json, unsupported_media_type, authentication_required, api_key_invalid, idempotency_key_required, idempotency_in_progress, approval_policy_denied, rate_limited, internal_error.