## 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](./create.md) and [rotation](./rotate-secret.md), 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

<!-- scheduled-job-verifier:start -->
```ts
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)
}
```
<!-- scheduled-job-verifier:end -->

### Read HTTP without changing the body

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

<!-- scheduled-job-http-adapter:start -->
```ts
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
}
```
<!-- scheduled-job-http-adapter:end -->

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.

```ts
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](./daily-digest.md) connects verification, enqueuing and observing completion.
