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

ConstraintLimitSource
Entries per object20 (default)Metadata::MAXIMUM_ENTRIES
Key length40 characters maxValidation
Key formatNo square brackets [ or ]Validation
Value length100 characters maxValidation
WhitespaceStripped from both key and value before savestrip_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.