Metadata
Frame's metadata is a way to attach your platform's identifiers and context to Frame resources. Order IDs, internal customer IDs, feature flags, idempotency UUIDs, support-ticket references — anything you'd want to query for later when you have a Frame record and need to reconcile back to your own data.
It's available on most resources: Transfer, Customer, Account, Subscription, Invoice, Coupon, PromotionCode, BillingMetricEvent, and others. The shape is identical across resources.
How it's stored
Frame stores metadata as discrete records, not as a JSONB blob on the parent resource. There's a metadata table with polymorphic metadatable_id / metadatable_type columns pointing back to whatever resource owns the entries. Each k/v pair is a separate row.
This shape matters for two reasons:
- Queries. You can index and query metadata across resources without parsing JSON. Looking up "all transfers where my internal order ID equals X" is a database query, not a scan.
- Limits are per-row, not per-blob. The constraints below apply to individual entries.
Limits
| Constraint | Limit | Source |
|---|---|---|
| Entries per object | 20 (default) | Metadata::MAXIMUM_ENTRIES |
| Key length | 40 characters max | Validation |
| Key format | No square brackets [ or ] | Validation |
| Value length | 100 characters max | Validation |
| Whitespace | Stripped from both key and value before save | strip_whitespace callback |
Hitting the entry limit returns an error like "Cannot have more than 20 metadata entries per object". Some resources override metadata_limit to allow more — most stick to the 20-entry default.
The character limits are strict; values longer than 100 chars get rejected outright (not truncated). For longer payloads (a full JSON blob, a long URL, a description), store the data on your side and put a reference key in Frame's metadata.
Setting metadata on create
curl --request POST \
--url https://api.framepayments.com/v1/transfers \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"amount": 5000,
"currency": "USD",
"account_id": "<account_id>",
"payment_method_id": "<payment_method_id>",
"metadata": {
"order_id": "order_42",
"channel": "web",
"promo_applied": "SUMMER20"
}
}'
The metadata field accepts an object with string keys → string values. Frame creates one Metadata row per pair. Each row is independently size-validated.
Updating metadata
PATCH on the resource accepts the same metadata field. The semantics: the new object merges into the existing set — keys you send are added or overwritten, keys you don't send are preserved.
curl --request PATCH \
--url https://api.framepayments.com/v1/transfers/<id> \
--data '{
"metadata": {
"fulfilled_at": "2026-06-05T14:00:00Z"
}
}'
If the transfer had { order_id: "order_42", channel: "web", promo_applied: "SUMMER20" } before, after this PATCH it has all four keys — the existing three plus the new fulfilled_at.
To delete a single key without touching the rest, pass it with an empty value (Frame's merge uses compact_blank, so empty/null values drop the key):
curl --request PATCH \
--url https://api.framepayments.com/v1/transfers/<id> \
--data '{
"metadata": {
"promo_applied": ""
}
}'
To clear the entire metadata set in one call, pass _delete_all: true:
curl --request PATCH \
--url https://api.framepayments.com/v1/transfers/<id> \
--data '{
"metadata": { "_delete_all": true }
}'
The 20-entry cap still applies after merge — if your new keys would push the total above 20, the PATCH fails validation.
Reading metadata
Metadata comes back on the resource's GET response, inlined as an object:
{
"id": "tr_...",
"amount": 5000,
...
"metadata": {
"order_id": "order_42",
"channel": "web"
}
}
For querying by metadata value across many records — "list all transfers where metadata.order_id = order_42" — Frame doesn't expose a generic metadata-filter on list endpoints in V1. You'd need to query Frame's logs, paginate through results, or maintain your own forward-index from your side.
What to put in metadata
Good fits:
- Your internal IDs (
order_id,internal_customer_id,subscription_plan_id). - Routing hints (
channel: "web",region: "us-east"). - Feature flags or A/B-test buckets.
- Idempotency UUIDs (see idempotency).
- Support-ticket refs (
zd_ticket: "123456").
Don't put in metadata:
- PII or sensitive data. Frame doesn't promise that metadata is treated with the same protections as primary resource fields; treat it as queryable by Frame ops and support.
- Anything longer than 100 chars. Store the long-form on your side; reference it via a key in Frame metadata.
- Anything you need to query in bulk. The lack of a list-filter endpoint means metadata-scoped queries are slow at volume.
Webhook payloads include metadata
When a resource fires a webhook, the resource's metadata is included in the payload. This is the primary reason to put your internal IDs in metadata — so your webhook handler can route the event to the right internal record without an extra lookup.
async function handleTransferSucceeded(event) {
const orderId = event.data.metadata?.order_id;
if (!orderId) return;
await fulfillOrder(orderId, { transfer_id: event.data.id });
}
Gotchas
Symptom: Cannot have more than 20 metadata entries per object error on create. Why: you exceeded the default limit. Fix: prune to the entries that actually matter for downstream queries — most use cases need 2-5 keys, not 20. If your platform genuinely needs more, talk to Frame support about raising the metadata_limit for specific models.
Symptom: you tried to put a value longer than 100 chars and got a validation error. Why: hard limit. Fix: store the long-form on your side, put a reference key in metadata (e.g., note_id: "note_42" instead of the full note text).
Symptom: you PATCH'd a resource with metadata: {} (empty object) expecting to clear the set, but the existing keys stayed. Why: PATCH merges — empty object means "no changes to merge in." Fix: pass metadata: { _delete_all: true } to clear all entries, or pass each key you want to drop with an empty value.
Symptom: you tried a key with square brackets (e.g., order[id]) and got rejected. Why: the format validator rejects [ and ] in metadata keys. Fix: use a flat key (order_id) or a different separator (order.id, order_internal_id).
Symptom: you can't filter list endpoints by metadata value. Why: not exposed in V1. Fix: maintain a forward-index from your application — when you create a Frame record with metadata, also store the Frame ID against your own primary key. Bulk lookup by metadata isn't supported today; flag with Frame if it's blocking.
Reference
Metadata appears in the request body of most create + update endpoints. See POST/v1/transfers for a representative example.