External Sources
RecordTypes can be configured to proxy any REST API. Agents use the same manage_record and sql_query tools — they don't know whether data lives in Rekor or an external system.
How It Works
Add a sources config to any record_type. Each source defines API endpoints, authentication, and field mapping. When a record operation includes an external_source that matches a configured source, Rekor proxies the operation to the external API.
rekor record-types upsert invoices \
--name "Invoices" \
--schema '{"type":"object","properties":{"amount":{"type":"number"},"status":{"type":"string"}}}' \
--sources '[{
"name": "stripe",
"auth": {
"header": "Authorization",
"value_template": "Bearer {{secret}}",
"secret": "sk_live_..."
},
"field_mapping": {
"to_external": {
"amount": "amount_due",
"status": "invoice_status"
}
},
"get": { "url": "https://api.stripe.com/v1/invoices/{{external_id}}", "method": "GET", "response_path": "data" },
"list": { "url": "https://api.stripe.com/v1/invoices", "method": "GET", "response_path": "data", "total_path": "total_count" },
"create": { "url": "https://api.stripe.com/v1/invoices", "method": "POST" },
"update": { "url": "https://api.stripe.com/v1/invoices/{{external_id}}", "method": "POST" }
}]'
Endpoint Templates
Each endpoint's url and static headers can embed {{...}} placeholders that are filled in per request. Which placeholders are available depends on the operation:
| Placeholder | Resolves to | Operations |
|---|---|---|
{{external_id}} | The record's external ID | get, update, delete |
{{query.*}} | A request query parameter | get, list, update, delete |
{{data.*}} | A field from the record body | create, update |
{{current.*}}, {{prior.*}} | The record's current stored values from the upstream (needs a get endpoint) | update, delete |
{{limit}}, {{offset}} | Pagination | list |
{{auth.org_id}}, {{auth.base_id}} | The caller's organization / base | all |
"get": {
"url": "https://api.example.com/eligibility/{{external_id}}?plan={{query.plan}}",
"method": "GET",
"headers": { "X-Org": "{{auth.org_id}}" },
"response_path": "data"
}
Configured headers are sent with every request to the source; if a static header collides with the authentication header, the authentication header always wins. URLs must be absolute https URLs (or http only when the source opts in with allow_insecure_http, for a legacy or on-prem upstream that doesn't offer TLS), and placeholders may appear only in the path and query string — never in the scheme, host, or credentials. These rules are checked when you save the record_type.
Non-REST & Legacy Upstreams
Plain "one HTTP call per read/write" upstreams that aren't clean REST/JSON — RPC-style verb paths, all-POST APIs, form posts, envelope responses — can still be backed directly, without a custom service, using a few declarative knobs. Per-operation method and url already let each operation be any verb path (for example "list": { "url": ".../listar", "method": "POST" }), and response_path pulls records out of an envelope; the knobs below cover the rest.
Form-encoded request bodies
Set request_encoding to "form" to send application/x-www-form-urlencoded bodies instead of JSON (default "json"). Use it for upstreams that take form posts.
"request_encoding": "form"
Constant body parameters
Add static_body for constant, non-secret values that every request to the upstream must carry but the agent never supplies — a fixed tenant or clinic id, for instance. They're merged into the body of every body-bearing request, including reads done over POST. Agent-supplied data and secret injections take precedence on a key collision. (For constant query parameters, bake them into the url; for constant headers, use the endpoint's headers; for secrets, use injections.)
"static_body": { "tenant_id": "35" }
Putting the record id into the request body
Some RPC-style or legacy upstreams identify the record to update or cancel by an id in the body, not the URL. Add a body_template to the operation: a map of body field → a template string filled with the same per-request placeholders the url uses ({{external_id}}, {{data.*}}, {{query.*}}, {{auth.org_id}}/{{auth.base_id}}). It applies to create, update, and a delete whose method carries a body (POST/PUT/PATCH); it's rejected on reads and on GET/DELETE-method endpoints, which send no body. Unlike static_body, whose values are constants, body_template values are filled per request — and on a key collision your data is overlaid by body_template, with secret injections winning over both. Values are filled as strings (the same as URL and header tokens); for a numeric or boolean field an upstream is strict about, send it through field_mapping or static_body instead.
"update": { "url": ".../atualizar", "method": "POST", "body_template": { "agendamento_id": "{{external_id}}" } }
Updating or cancelling with the record's current values
Some upstreams need the record's current stored state on a write, not just its id and the new values — an optimistic-concurrency check (If-Match/etag/version), a "move"-style update that takes the OLD value alongside the NEW (a reschedule that wants the current date as well as the new one), or a cancel keyed by the current field values. Reference them in an update or delete template (URL, header, or body_template) with {{current.<field>}} or {{prior.<field>}}. Rekor sources them with a read through the source's get endpoint just before the write, so a get endpoint is required when you reference them — and the values are the upstream's own record, with its field names and wire format, echoed back verbatim. prior is an alias of current. Referencing them costs one extra read per write; an update or delete that doesn't reference them pays nothing.
"get": { "url": ".../agendamento/{{external_id}}", "method": "GET", "response_path": "data" },
"update": { "url": ".../atualizar", "method": "POST",
"body_template": { "agendamento_id": "{{external_id}}",
"data_agendada": "{{current.data_agendada}}",
"nova_data": "{{data.data_agendada}}" } }
Success/error envelopes
Some upstreams return HTTP 200 even on a logical failure, signalling the real outcome in the body (for example { "success": false, "message": "slot already taken" }). Point success_path at the body flag and message_path at the human-readable message: when the flag is falsy, Rekor surfaces the call as an error carrying the upstream message, instead of mistaking the envelope for data. Both are dot-paths (the same style as response_path).
"success_path": "success",
"message_path": "message"
Records keyed on a domain field
Rekor derives each record's external ID from a conventional key (id, _id, Id, ID, uuid, or key). If your upstream keys records on a domain field instead (for example codigo or a nested identifiers.cpf), point id_path at it — a dot-path into each raw record, the same style as response_path. It applies to list (per record) and create (from the response). Set this when your upstream has no conventional ID field, otherwise a list returns no records and a create can't identify the new record.
"id_path": "codigo"
When an upstream's identity is composed of several fields with no single natural ID (an availability slot keyed on resource + date + time, a join row keyed on a pair of IDs), make id_path a template that joins them. A composite ID is list-only — it can't address a single record by ID, so it's rejected if the source also configures get, create, update, or delete.
"id_path": "{{resource}}::{{date}}::{{time}}"
Some RPC-style upstreams return the new record's server-assigned ID on create under a different field than the one that keys list rows. When no single id_path can satisfy both, set a create-only id_path on the create endpoint to map the create-response ID independently; it overrides the source-level id_path for the create read-back and falls back to it when unset.
"id_path": "row_key",
"create": { "url": ".../incluir", "method": "POST", "response_path": "data", "id_path": "created_ref" }
For an action endpoint — a write that performs something (enroll, cancel, mark) and returns no addressable record, just an empty body or a bare { "result": "ok" } — make the create-only id_path a template over the create inputs instead. Rekor derives the external ID from the request data, so identical inputs map to the same ID and a repeated identical action updates the same record rather than creating a duplicate. The tokens must name fields the create accepts, and the source must be create-only (a list is fine): a request-built ID can't address a single record, so get, update, and delete are rejected.
"create": { "url": ".../enroll", "method": "POST",
"id_path": "{{data.member_id}}::{{data.enrollment_id}}::{{data.date}}" }
Forwarding filters to the upstream
By default a proxied list returns whatever the upstream's list endpoint returns, and a filter is rejected — Rekor won't pretend to have filtered records it can't. Many upstreams, though, require an input to return anything: search a record by key, list a day's rows, find the children of a parent. Add forward_filters to the source's list endpoint to forward the agent's filter conditions to the upstream. fields is the allowlist of fields that may be forwarded (each translated to the upstream's own name and value through field_mapping); target chooses whether they go in the query string or the request body, inferred from the list method when omitted. Exact-match conditions are forwarded today; any condition that can't be forwarded is rejected with a clear error rather than silently returning unfiltered records.
"list": {
"url": "https://api.example.com/patients/search",
"method": "GET",
"forward_filters": { "fields": ["email"] }
}
An agent then lists with a filter exactly as it would for any record_type — rekor records query patients --external-source legacy --filter '{"field":"email","op":"eq","value":"a@b.com"}' — and Rekor forwards email to the upstream.
A forwarded value is also exposed to the read mapping as {{filter.<field>}}, usable in a computed field or a composite id_path. This backfills a value the upstream leaves out of its list rows: a list-by-date endpoint that returns each row's time but not the date can compose a canonical starts_at from {{filter.date}} plus the row's time. It applies to list only (the one operation that forwards a filter), and each referenced field must be in list.forward_filters.fields.
Secret Injections
Beyond the primary auth credential, a source can inject additional stored secrets into each proxied request — useful when the upstream itself needs a per-customer credential (an operator login, a downstream API key). Add an injections list to the source:
"injections": [
{ "target": "header", "key": "X-Operator-Token", "secret_ref": "vault:operator-{{auth.base_id}}" },
{ "target": "body", "key": "credentials.api_key", "secret_ref": "vault:partner-key", "value_template": "Bearer {{secret}}" }
]
Each injection resolves a named secret from the vault at request time, places it in a request header or a body field (dot-path), optionally wrapping it via value_template ({{secret}}), and discards it after the call. Injected headers can never override the authentication header.
Which secret is resolved is controlled entirely by the platform, never by request input: secret_ref may reference the caller's own org or base ({{auth.org_id}} / {{auth.base_id}}) so each base resolves its own credential. A secret is only resolvable by a base its scope admits — scope a secret to specific bases or tags to share one credential across a group of bases while keeping it inaccessible to others.
Request Signing & Idempotency
Every proxied write (create, update, delete) carries an X-Rekor-Idempotency-Key header derived deterministically from the logical operation — the same operation always produces the same key, so an upstream can safely dedupe retries and never double-execute.
When a source points at an executor that must verify requests genuinely came from Rekor, add a signing block:
"signing": { "secret_ref": "vault:sign-key" }
Every proxied request to that source — reads included — is then HMAC-signed and carries X-Rekor-Signature (format v1,<hex>), X-Rekor-Timestamp (unix seconds), and X-Rekor-Id headers the executor can verify. The signature binds the scheme, id, timestamp, method, path, and body, so it can't be replayed across requests or replayed late (the executor rejects a stale timestamp). The same scheme is used for trigger webhooks, so one verifier handles both. Like injections, secret_ref is a vault reference that may template only {{auth.org_id}} / {{auth.base_id}} for per-tenant signing keys, never request input. Omit signing for third-party APIs (Stripe, Salesforce) that use their own authentication. Build the receiving service with rekor-sdk — see Building an Executor.
Executor Credentials
When the executor itself needs a binary or large per-tenant credential it can't take inline — typically a client certificate to present over mutual-TLS to a downstream upstream — declare it on the source as executor_secrets and let the executor pull it at dispatch:
"executor_secrets": [{ "name": "partner-cert", "secret_ref": "vault:partner-cert" }]
On every proxied call Rekor advertises a short-lived, single-use grant to the executor, which fetches the named credential back by name. The certificate stays in Rekor's vault — scoped, audited, and rotatable — while the executor stays stateless. See Building an Executor for the full pull pattern.
Agent Experience
The agent uses the exact same tools for proxied and native records:
# Proxied to Stripe API
manage_record(upsert, record_type: "invoices", external_id: "inv_123", external_source: "stripe", data: {amount: 5000})
# Stored in Rekor
manage_record(upsert, record_type: "notes", data: {title: "Follow up"})
Field Mapping DSL
Map between Rekor's canonical schema and external API formats. Supports simple renames and rich transformations. field_mapping is optional — omit it to pass records through unchanged (the upstream object is stored verbatim, and writes send your data as-is).
Simple Rename
{ "status": "invoice_status" }
Nested Path
{ "customer_name": { "path": "account.contact.full_name" } }
Enum Mapping
{ "status": { "path": "state", "values": { "active": "ACTIVE", "draft": "DRAFT" } } }
Type Transform
{ "amount": { "path": "total", "transform": "to_number" } }
Available transforms: lowercase, uppercase, trim, to_string, to_number, to_boolean, iso_date
Default Values
{ "currency": { "path": "currency", "default": "usd" } }
Array Access
{ "first_item": { "path": "items[0].name", "array_mode": "first" } }
Array modes: first, last, flatten
Date/Time Reshaping
{ "due_date": { "path": "vencimento", "date_format": "dd/MM/yyyy" } }
Reshape dates and times between the upstream's wire format and Rekor's canonical ISO-8601, in both directions. date_format is the external format; the Rekor side is ISO-8601 by default, or set rekor_format to reshape the canonical side too (for example date_format: "HH:mm:ss" with rekor_format: "HH:mm" to drop seconds). Tokens: yyyy, MM, dd, HH, mm, ss, plus literal separators. Rich rules don't auto-invert, so add an explicit to_rekor entry (or a computed field) to reshape the reverse direction.
Split one field into several parameters
{ "starts_at": { "targets": [
{ "path": "data", "date_format": "dd/MM/yyyy" },
{ "path": "hora", "date_format": "HH:mm" }
] } }
On to_external, give a rule targets (an array of rules) instead of a single path to fan one canonical field out to several upstream parameters, each with its own path/transform/date_format/target. The headline use: feed a single ISO datetime into separate date and time parameters. A split field can't be a forwarded filter field — it has no single upstream name.
Send a parameter in the query string
{ "activity_id": { "path": "idActivity", "values": { "lash-lift": "42" }, "target": "query" } }
On to_external, set target: "query" (default body) to route a mapped parameter into the request's query string instead of the body on create and update — the write-direction counterpart of forwarding filters to the query string on a list. The value map, transform, and default still apply, so this backs an upstream whose create/update parameters are query (not a JSON body) and need a canonical value translated to an upstream code: the example sends ?idActivity=42. A query-target name that collides with a parameter already in the endpoint's URL is rejected when the source is configured. Each split target can choose its own target.
Decompose a composite ID into several parameters
{ "session_id": { "separator": "::", "parts": [
{ "segment": 0, "path": "idActivity", "target": "query" },
{ "segment": 2, "path": "activityDate", "date_format": "dd/MM/yyyy", "target": "query" }
] } }
On to_external, give a rule a separator and a parts array to split ONE composite canonical value on the separator and route each part to its own upstream parameter — the write-side inverse of a composite id_path. Use it when a write tool references an entity by its composite ID — say session_id = "<activity>::<config>::<date>::<time>" — but the upstream action endpoint needs the pieces as separate parameters, each independently value-mapped, formatted, and routed to the query string or body. Parts are positional by default, in the same order the ID concatenates them. To consume a different — possibly non-contiguous — subset, give each part an explicit segment index and declare only the parts you need: one canonical ID carrying every identifier any action might need then serves several write actions that each extract a different subset, with no duplicated record_type (the example takes segments 0 and 2, dropping the rest). Within a rule either every part declares a segment or none does, and the declared indices are unique. A destructured field can't be a forwarded filter field — it has no single upstream name.
Computed fields
{ "starts_at": {
"template": "{{data}}T{{hora}}",
"sources": {
"data": { "date_format": "dd/MM/yyyy", "rekor_format": "yyyy-MM-dd" },
"hora": { "date_format": "HH:mm", "rekor_format": "HH:mm:ss" }
}
} }
The computed map builds a field on read from a {{token}} template over the raw upstream record — the inverse of a split. Use it to rebuild one field from several upstream fields (for example an ISO datetime from separate date and time), or to derive a key from sibling fields. The optional sources reshapes each token the same way a to_rekor rule does; if any token is missing the whole field is omitted. Tokens reference the upstream's own field names — or, with {{filter.<field>}}, a value forwarded to the upstream list query (see Forwarding filters to the upstream), to backfill a scope key or date the upstream omits from its list rows.
Per-operation override
The source-level field_mapping applies to every operation. Some RPC-style or legacy upstreams, though, use different parameter names for the same logical field across verbs — for example create takes entity_id while update takes id_entity. Put a field_mapping on the individual get/list/create/update endpoint to override the source-level one for that operation; it falls back to the source-level mapping when unset. The override is a full replacement (not a merge), so set every field that operation needs. (It can't go on delete — control delete's param names through its URL template, or, for a body-bearing delete method, with body_template.)
"field_mapping": { "to_external": { "owner": "entity_id" } },
"update": { "url": ".../atualizar", "method": "PUT", "field_mapping": { "to_external": { "owner": "id_entity" } } }
Named write bindings
Sometimes one canonical entity's writes fan out, in the backend, to several endpoints with different URLs, parameters, and id-semantics — booking a trial versus booking a make-up are both creates of the same booking, but hit different upstream actions. Instead of splitting that into one record_type per endpoint, declare a write operation (create, update, or delete) as a map of named variants. Each variant is a full endpoint — its own url, method, field_mapping, id_path, body_template, response_path, headers — and they all share the source's transport (authentication, secret injections, signing, breaker, caching, encoding, constant body). The caller selects one by name per write, so a single canonical record_type serves the whole write topology.
"create": {
"trial": { "url": ".../experimental-class", "method": "POST", "id_path": "session_id" },
"makeup": { "url": ".../enroll", "method": "POST", "id_path": "{{data.member_id}}::{{data.session_id}}" }
}
A binding named default is used when a write selects none (for example an external-write trigger, which dispatches without naming one — so a source used as an external-write target must give the targeted operation a single endpoint or a default). Reads (get/list) don't fan out, so they stay a single endpoint. The single-endpoint form is the default; at the curated toolset layer a tool picks which binding it dispatches to (see the MCP Factory docs). Selecting a binding an operation doesn't declare — or naming one on a single-endpoint operation — is rejected when the source is saved and again at promotion.
Caching
Set cache_ttl (seconds, up to 7 days) on a source to serve repeat reads — both single-record fetches and list results — directly from Rekor for the duration of the TTL, without re-hitting the upstream API. A write through the source invalidates the cached record and clears its cached list pages, so reads never go stale after a change.
"cache_ttl": 3600,
"stale_if_error": true
Add stale_if_error: true to keep serving the last-known value when the upstream is temporarily unreachable or rate-limited. Each proxied read is tagged with a _cache marker — hit (served from cache), miss (freshly fetched and cached), or stale (last-known value served during an upstream outage) — so callers can tell where the data came from. Reads that pass different query parameters are cached independently, and each list page (limit/offset) is cached on its own.
Timeouts
Set timeout_ms (milliseconds, default 10s, max 30s) on a source to bound how long Rekor waits for the upstream API before giving up. A slow upstream can't stall the request indefinitely — once the timeout is exceeded the call is treated as a transient upstream failure, so a read with stale_if_error: true falls back to the last-known cached value instead of hanging or erroring. (Note the unit: cache_ttl is in seconds, timeout_ms is in milliseconds.)
"timeout_ms": 5000
Circuit breaker
When an upstream keeps failing — timeouts, 5xx, or connection errors — Rekor stops hammering it. After a few consecutive failures the source "opens": further calls fail fast (serving the last-known cached value when stale_if_error is set, otherwise a clear "temporarily unavailable" response) instead of every request waiting out the timeout. After a short cooldown Rekor sends a single probe; if it succeeds, normal traffic resumes. This is always on with sensible defaults; tune it per source if needed.
"breaker": { "failure_threshold": 5, "cooldown_ms": 30000 }
Modes
| Mode | Reads | Writes | Source of Truth |
|---|---|---|---|
| Native | Rekor | Rekor | Rekor (default) |
| Proxy | External API (optionally cached) | External API | External system |
| Sync In (planned) | Rekor | External API | External system |
| Native + Sync Out (planned) | Rekor | Rekor → External | Rekor |
Multiple Sources
A single record_type can have multiple external sources. The external_source field on each record determines which backend handles it. Records without an external_source use Rekor's native storage.
Deterministic IDs
Proxied records get stable Rekor IDs derived from (record_type, external_source, external_id). This means proxied records can participate in relationships — linking a Stripe invoice to an internal project works seamlessly.
Security
Source secrets — the primary credential and any injected secrets — are encrypted at rest and never returned in API responses. They are decrypted only at request time, scoped to the calling base (a secret is only used if its scope admits that base), and used solely for that one upstream call.