CandidateInbox API

Give each of your users a professional address for their job search. Mail to it is forwarded to them in full, hiring-site verification links are handed to you, and every email is matched to the application it's about.

Authentication

Send your platform's API key as a bearer token on every request. Keys are issued by CandidateInbox and shown once; ask for a new one if it's lost. All requests and responses are JSON.

curl https://candidateinbox.com/v1/addresses/ADDRESS_ID \
  -H "Authorization: Bearer ci_live_..."

Endpoints

MethodPathBodyWhat it does
POST/v1/addresses{ externalUserId, firstName, lastName, forwardTo, consentAt }Create the person's address, or return the one they already have (201 created, 200 existing)
GET/v1/addresses/:id—The address, its status and forwarding target
PATCH/v1/addresses/:id{ forwardTo }Change where mail is forwarded
POST/v1/addresses/:id/close—Close the address; mail to it is refused from then on
PUT/v1/addresses/:id/applications[{ id, company, role, appliedAt }]Replace the list of applications emails are matched to
GET/v1/addresses/:id/messages?since=&limit=—Received messages, newest first (limit 1–100, default 50)
GET/v1/addresses/:id/verification-link?since=&site=—The latest verification link; 404 until one arrives

Addresses are firstname.lastname@candidateinbox.com, with a number added only when the name is taken (jane.doe2). One address per person per platform: creating again with the same externalUserId returns the existing address. consentAt is required: the time the person agreed to the address being used for hiring-site accounts.

Example: create an address

POST /v1/addresses
{
  "externalUserId": "user_8412",
  "firstName": "Jane",
  "lastName": "Doe",
  "forwardTo": "jane@example.com",
  "consentAt": "2026-10-20T14:03:00Z"
}

201 Created
{
  "id": "6720c0ffee0000000000abcd",
  "address": "jane.doe@candidateinbox.com",
  "status": "active",
  "externalUserId": "user_8412",
  "firstName": "Jane",
  "lastName": "Doe",
  "forwardTo": "jane@example.com",
  "consentAt": "2026-10-20T14:03:00.000Z",
  "applications": [],
  "createdAt": "2026-10-20T14:03:01.000Z",
  "closedAt": null
}

Errors

Errors use the HTTP status and a stable code:

{ "error": { "code": "invalid_forward_to", "message": "forwardTo must be a valid email address." } }
StatusCodeMeaning
400invalid_*A field is missing or malformed; the message says which
401unauthorizedMissing, invalid or disabled API key
404not_foundNo such address for your platform (other platforms' addresses are never visible)
409address_closedThe address is closed
500internal_errorOur fault; retry with backoff

Webhooks

Events are POSTed to your webhook URL as JSON. Respond with any 2xx within 10 seconds. Anything else is retried 5 more times over about 2 hours (after 1, 5, 15, 30 and 60 minutes). Use the event id to ignore duplicates.

EventSent whendata
message.receivedAn email arrived for one of your addressesmessage fields
verification.linkA hiring-site verification email arrivedmessage fields + site, link
message.sortedAn email was sorted and matchedmessage fields + kind, applicationId, summary
forward.failedForwarding to the person's email failed after all retriesmessage fields + error
address.closedAn address was closedaddressId, address, closedAt
pingTest event sent from the admin—

Message fields: addressId, messageId, from, fromName, subject, receivedAt. kind is one of received, rejection, interview, assessment, offer or other; applicationId is one of the ids you sent with PUT …/applications, or null when no application matches.

POST https://your-app.example/hooks/candidateinbox
X-CandidateInbox-Event: message.sorted
X-CandidateInbox-Signature: t=1792540000,v1=5f2c…

{ "id": "evt_…", "type": "message.sorted", "createdAt": "2026-10-20T14:10:00.000Z",
  "data": { "addressId": "6720c0ffee0000000000abcd", "messageId": "6720c0ffee0000000000f00d",
            "from": "talent@acme.com", "fromName": "Acme Talent", "subject": "Next steps",
            "receivedAt": "2026-10-20T14:09:58.000Z", "kind": "interview",
            "applicationId": "lead_5521", "summary": "Invites the candidate to a 30-minute phone screen." } }

Verifying webhook signatures

Each request is signed with your platform's signing secret (whsec_…). Compute an HMAC-SHA256 of `${t}.${rawBody}` and compare it with v1. Reject requests whose t is more than 5 minutes old.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}