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
| Method | Path | Body | What 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." } }| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_* | A field is missing or malformed; the message says which |
| 401 | unauthorized | Missing, invalid or disabled API key |
| 404 | not_found | No such address for your platform (other platforms' addresses are never visible) |
| 409 | address_closed | The address is closed |
| 500 | internal_error | Our 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.
| Event | Sent when | data |
|---|---|---|
| message.received | An email arrived for one of your addresses | message fields |
| verification.link | A hiring-site verification email arrived | message fields + site, link |
| message.sorted | An email was sorted and matched | message fields + kind, applicationId, summary |
| forward.failed | Forwarding to the person's email failed after all retries | message fields + error |
| address.closed | An address was closed | addressId, address, closedAt |
| ping | Test 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));
}