Building an Executor

An executor is a small, stateless HTTP service you deploy to act on the outside world on Rekor's behalf. Rekor is the system of record: it holds documents, brokers credentials, and dispatches signed requests. The executor receives those requests, does the real work — call a third-party API, present a client certificate, run logic Rekor can't — and writes the result back.

Reach for an executor when an agent needs a side effect Rekor itself can't perform: calling an external API on a schedule or on data change, talking to a legacy system, presenting a certificate, or running heavier processing.

Agent → Rekor (record + sign + dispatch) → Executor (verify + act) → result back to Rekor

Rekor reaches an executor two ways, both signed identically:

  • Triggers — Rekor POSTs to the executor's URL when documents change (outbound webhooks).
  • External sources — Rekor proxies a document read or write through to the executor's URL.

Do you even need an executor?

If your upstream is a REST/JSON API Rekor can call directly, configure an external source with field mapping — no executor required. Build an executor only when:

  • a trigger must run custom logic when documents change, or
  • an external-source call can't be a direct HTTP request Rekor makes itself — mutual-TLS, SOAP or raw TCP, binary per-tenant credentials, multi-call orchestration, or heavier processing.

An executor always receives Rekor's outbound dispatches (triggers and external sources). Hooks are the reverse direction — your executor calls a hook to write results back into Rekor, which Rekor receives. One rekor-sdk covers both sides: createExecutor verifies what Rekor sent you; signRequest signs what you send back.

The One Rule: Never Hand-Roll Verification

Every dispatched request is HMAC-signed and carries a timestamp and an idempotency key. Verifying that correctly — constant-time compare, timestamp skew, signature-list rotation, exact body bytes — is easy to get subtly wrong, and a mistake means anyone can forge a request to your executor. The rekor-sdk package does it for you and is the only supported path:

import { createExecutor, toFetchHandler, ExecutorError } from 'rekor-sdk'

const handler = toFetchHandler(createExecutor({
  secret: process.env.REKOR_SIGNING_SECRET!, // the source/trigger signing secret
  handler: async (ctx) => {
    // ctx.body is the verified raw payload; ctx.idempotencyKey dedupes retries automatically.
    const event = JSON.parse(ctx.body)
    // ...do the work (call the upstream, transform, etc.)...
    // Return a result to send back, or nothing for a 204.
    return { status: 200, body: JSON.stringify({ ok: true }), contentType: 'application/json' }
    // Throw new ExecutorError({ status, code, message, retriable }) to control retries:
    // retriable -> Rekor retries with backoff; non-retriable 4xx -> Rekor stops.
  },
}))

// Wire handler to your framework's route:
//   Hono:       app.post('/rekor', (c) => handler(c.req.raw))
//   Cloudflare: export default { fetch: handler }
//   Next.js:    export const POST = (req: Request) => handler(req)

For Node/Express, use the Node adapter instead — and mount it before any JSON body parser, since the adapter needs the raw bytes:

import express from 'express'
import { createExecutor, toNodeHandler } from 'rekor-sdk'

const rekor = toNodeHandler(createExecutor({ secret: process.env.REKOR_SIGNING_SECRET!, handler: async (ctx) => { /* ... */ } }))
const app = express()
app.post('/rekor', rekor) // mount BEFORE any JSON body parser

The SDK gives you, for free: signature and timestamp verification, automatic dedupe on the idempotency key (so an at-least-once delivery never double-acts), and a normalized error envelope. You write one handler function.

The Contract

What every executor must do:

  • Verify every request (the SDK does this — reject unsigned, invalid, or stale requests).
  • Scope all work to the signed request — never trust a tenant id from the request body.
  • Dedupe on the idempotency key (the SDK does this with a default in-memory store; supply a shared store if you run multiple instances).
  • Stay stateless — keep no durable per-tenant state. Credentials and records live in Rekor; the executor holds at most an ephemeral in-memory cache.
  • Return the normalized error envelope { status, code, message, retriable } (throw ExecutorError).
  • Keep secrets and PII out of logs.

Writing Results Back

Return results inline for a synchronous action, or — for a slow or async action — POST the result to a Rekor hook. Sign that write-back with the SDK's signRequest (the inverse of the verify side):

import { signRequest } from 'rekor-sdk'

const url = 'https://api.rekor.pro/v1/<db>/hooks/<hook_id>/ingest'
const body = JSON.stringify({ collection: 'results', data: { /* ... */ } })
const headers = await signRequest({ secret: process.env.REKOR_HOOK_SECRET!, method: 'POST', url, body })
await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })

Where to Run It

Default to a serverless platform. Cloudflare Workers, Vercel, Deno Deploy, AWS Lambda — pick whichever you already use; the executor's contract is platform-agnostic. Most executors are just authenticated API calls, and a serverless function is cheap, fast, scales to zero, and plugs straight into the SDK's Fetch handler.

Step up to a long-running container or server (Fly.io, Railway, Render, a VM — anything that holds an open process, via the SDK's Node adapter) only when a serverless function can't do the job:

  • Mutual-TLS / client certificates — e.g. a bank or government API that requires an A1 cert.
  • Long-running work beyond a serverless function's CPU or time budget.
  • Raw TCP, SOAP, or other non-HTTP protocols.
  • Native dependencies — heavy crypto or language runtimes a serverless function can't host.

These are exactly the cases Rekor deliberately can't handle itself — so they live in the executor.

Certificate-Bearing Integrations (the Vault Pattern)

When an upstream requires a client certificate, store it in Rekor's vault (base64-encoded) with a content_type so it's a first-class, rotatable credential — not a file baked into the executor:

# --file reads the .p12 / .pem and base64-encodes it; --content-type records the format.
rekor secrets create --name partner-cert --file ./partner.p12 --content-type application/x-pkcs12

Then declare it on the source so the executor can pull it at dispatch — the right pattern for a binary or per-tenant credential too large to inject inline:

{ "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. Pull the credential by name with fetchExecutorCredential, passing the inbound request (it carries the grant):

import { fetchExecutorCredential } from 'rekor-sdk'

// Inside your VERIFIED handler — pull ONCE per invocation and reuse (the grant is single-use per name).
// The grant headers aren't signature-bound, so if you sit behind a TLS-terminating proxy, pin your origin.
const cert = await fetchExecutorCredential(req, 'partner-cert', { allowedOrigins: ['https://api.rekor.pro'] })
const pfx = Buffer.from(cert.value, 'base64') // value is verbatim; decode per contentType
// ...present pfx over mutual-TLS to the upstream (mutual-TLS lives in the executor, not Rekor).

For a per-tenant cert, template the reference: "secret_ref": "vault:partner-cert-{{auth.database_id}}" resolves each calling database's own credential (only {{auth.org_id}} / {{auth.database_id}} are allowed). The cert stays in Rekor — the executor stays stateless, the pull is scoped and audited, and the credential rotates centrally.

Idempotency and Retries

Delivery is at-least-once: Rekor retries failed deliveries with backoff and surfaces status via rekor triggers deliveries. Because the same event can arrive more than once, the SDK dedupes on the idempotency key automatically — a replayed delivery returns the cached result without re-running your handler. If you run more than one executor instance, pass a shared store (implement the IdempotencyStore interface over your datastore) so dedupe holds across instances.

Local Development

Set devBypass: true to skip signature verification while developing locally (dedupe still applies if the headers are present). Never enable it in production — it disables the only thing stopping a forged request.

createExecutor({ secret, handler, devBypass: process.env.NODE_ENV !== 'production' })