Para agentes de IA: markdown de esta página — /docs-content-en/infra/jobs/signature.md índice de la documentación — /llms.txt

Los artículos de la documentación están disponibles actualmente en inglés.

Verify signatures

Each run is a POST to the configured path, with Content-Type: application/json, X-Vibe-Caller-Kind: schedule and Vibe-Signature. A manual run also uses caller schedule; the trigger field distinguishes manual from schedule. Accept requests only through a trusted platform ingress: close any direct public bypass where a client can set the caller, or enforce the same contract there. The caller alone does not replace a signature.

Bytes and secret

The secret is generated from 32 random bytes and returned as vjs_ plus 43 unpadded base64url characters, 47 characters in total. Use the UTF-8 bytes of the whole string including the prefix as the HMAC key; do not decode the suffix. Keep it only in protected server configuration. It is returned once on creation and rotation, never in list or history.

The signed message is ASCII(t) + b'.' + rawBody, using HMAC-SHA256 with 64 lowercase hex characters. t is the sending time in Unix seconds, new for each attempt. The UTF-8 JSON has no BOM or trailing newline and uses this key order: type, jobId, runId, scheduledFor, attempt, late, trigger. scheduledFor is UTC ISO-8601 with milliseconds; attempt is 1–4; late is the preserved first-attempt assessment (more than 60 seconds late); trigger is schedule or manual.

Verify the exact form of one physical header, freshness abs(now-t) <= 300 seconds, and compare HMAC in constant time. Repeated t/v1, extra fields, uppercase hex, unsafe integers and duplicate headers are rejected. Read rawBody before JSON.parse; reserialization changes the signed bytes. The bounded reader below accepts at most 64 KiB.

Verification code

TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto'
export interface VerifyJobRequestInput {
 callerKind: unknown
 singleSignatureHeader: unknown
 rawBody: Buffer
 secret: string
 nowSeconds: number
}
export function verifyJobRequest(x: VerifyJobRequestInput): boolean {
 if(x.callerKind!=='schedule'||typeof x.singleSignatureHeader!=='string') return false
 const m=/^t=(0|[1-9][0-9]{0,15}),v1=([0-9a-f]{64})$/.exec(x.singleSignatureHeader)
 if(!m||m[0]!==x.singleSignatureHeader) return false
 const t=Number(m[1])
 if(!Number.isSafeInteger(t)||!Number.isFinite(x.nowSeconds)||Math.abs(x.nowSeconds-t)>300) return false
 const actual=Buffer.from(m[2],'hex')
 const expected=createHmac('sha256',Buffer.from(x.secret,'utf8'))
  .update(m[1],'ascii').update('.','ascii').update(x.rawBody).digest()
 return actual.length===expected.length&&timingSafeEqual(actual,expected)
}

Read HTTP without changing the body

Do not use already joined headers: count physical headers in rawHeaders. HTTP header names are case-insensitive.

TypeScript
import type { IncomingMessage } from 'node:http'
export async function readRawBody(req: IncomingMessage): Promise<Buffer> {
 const chunks:Buffer[]=[]; let length=0
 for await(const chunk of req){
  const b=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk)
  length+=b.length
  if(length>65536) throw new Error('BODY_TOO_LARGE')
  chunks.push(b)
 }
 return Buffer.concat(chunks,length)
}
export function singleHeader(req: IncomingMessage,name:string): string|undefined {
 const values:string[]=[]
 for(let i=0;i<req.rawHeaders.length;i+=2)
  if(req.rawHeaders[i].toLowerCase()===name.toLowerCase()) values.push(req.rawHeaders[i+1])
 return values.length===1?values[0]:undefined
}

Wire the functions before parsing JSON; req is an IncomingMessage. Keep server clocks synchronized. Reject oversized bodies with 413, bad signature/caller with 401, and invalid payloads with 400. Never log the secret, signature or body.

TypeScript
const rawBody = await readRawBody(req);
const valid = verifyJobRequest({
  callerKind: singleHeader(req, 'X-Vibe-Caller-Kind'),
  singleSignatureHeader: singleHeader(req, 'Vibe-Signature'),
  rawBody,
  secret: process.env.JOB_SIGNING_SECRET!,
  nowSeconds: Math.floor(Date.now() / 1000),
});
if (!valid) { res.writeHead(401).end(); return; }
const payload = JSON.parse(rawBody.toString('utf8'));
// Validate the payload schema and expected jobId before durable acceptance.

Reference vector

The public test values below are not a production secret. The body is exactly 158 bytes without a newline; the test clock is 1791536400.

text
secret = vjs_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
Vibe-Signature: t=1791536400,v1=47f5954d1810b796a982c0eb461a948f4e80d531212cac1a4668321d24ea3008
JSON
{"type":"scheduled_job.run","jobId":"job_vector","runId":"run_vector","scheduledFor":"2026-10-09T09:00:00.000Z","attempt":1,"late":false,"trigger":"schedule"}

Secret rotation

Every new attempt uses the current secret. An already sent attempt may carry the old signature: the app can keep its previous key for a short 5-minute window and verify against both keys, or accept a refusal. Rotation does not cancel RUNNING, resign a sent request or recover permanent app-401/403. The app manages the previous key and its removal time itself.

Durable deduplication and outbox

After signature verification, atomically accept runId and enqueue durable work. All handler replicas use the same database. An in-memory Map/Set or a check-then-insert flow permits duplicate business operations. The PostgreSQL example below returns 202 only after commit.

sql
CREATE TABLE accepted_runs (
  run_id text PRIMARY KEY,
  accepted_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  response_status integer NOT NULL CHECK (response_status BETWEEN 200 AND 299),
  response_body jsonb NOT NULL
);
CREATE TABLE job_outbox (
  run_id text PRIMARY KEY REFERENCES accepted_runs(run_id),
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

$1 is the validated runId; $2 is the validated payload JSON. Bind driver parameters instead of interpolating strings into SQL. Execute all commands on one connection; on failure ROLLBACK and do not return 2xx. The separate SELECT after INSERT reads the saved response, including after a concurrent conflict.

sql
BEGIN;
WITH accepted AS (
  INSERT INTO accepted_runs(run_id, response_status, response_body)
  VALUES ($1, 202, '{"accepted":true}'::jsonb)
  ON CONFLICT (run_id) DO NOTHING
  RETURNING run_id
)
INSERT INTO job_outbox(run_id, payload)
SELECT run_id, $2::jsonb FROM accepted;
-- A separate statement sees a concurrent winner's committed receipt.
SELECT response_status, response_body FROM accepted_runs WHERE run_id = $1;
COMMIT;

Return the saved response_status/response_body after a successful commit; a repeat of an accepted runId creates no second outbox item and returns the same 2xx. A crash after commit but before responding is safe to retry. An outbox worker claims work using a lock/lease and marks processed_at after completion. For external effects use runId as the receiver's idempotency key: an outbox alone cannot guarantee exactly one external effect after a crash.

Keep acceptance receipts for at least the platform history retention: 90 days by default, at least 7 days, longer than the 96-hour delivery lifetime. Coordinate the retention setting with the platform; never delete an acceptance record or outbox item for unfinished work. Clean up only processed records after that period, outbox first, then accepted_runs. Signature freshness limits an old prepared request to five minutes, but new attempts of the same runId may arrive until deliveryDeadlineAt.

The digest example connects verification, enqueuing and observing completion.