Webhooks
Get a signed request at your own URL each time one of your cases changes state, instead of polling for it.
What is available today. Evictions API is a sandbox beta, in test mode only. Available today: sign-up for a free sandbox account, the online case form, the REST API and a sandbox with a fictional test state, where a built-in sandbox reviewer reviews cases automatically, you can step them through to close and receive webhooks. Not available yet: serving notices or filing in court for a real case, in any state, live mode, which would accept real cases and online checkout.
Register an endpoint
Register a URL with POST /v1/webhook-endpoints. From then on, every customer-visible event of your cases is posted to it. An endpoint belongs to the mode of the key that registered it: one registered with a test key receives events for test cases only. Each endpoint is tried on its own, so a slow or broken one does not hold up the others.
curl -X POST https://api.evictionsapi.com/v1/webhook-endpoints \
-H "Authorization: Bearer eak_test_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/webhooks/evictions" }'The response has the endpoint’s id and its signing secret, which starts with whsec_. The secret is in that response only and no later request returns it, so store it when you get it. You can list your endpoints with GET /v1/webhook-endpoints and delete one with DELETE /v1/webhook-endpoints/{id}, in the app (“Webhooks”) or through the API; the list never shows a secret. To change an endpoint, delete it and register it again.
The URL must be https, without a username or password, and not localhost or a private-network address. To receive events on your own machine, put a tunnel in front of it.
The event
Each delivery is a POST with a JSON body and two headers that let you check it came from us. The payloads carry no tenant data: no names, addresses, amounts or documents, only identifiers, the event name and the case’s state. Fetch the case with your API key when you need more.
POST /webhooks/evictions HTTP/1.1
Content-Type: application/json
X-Evictions-Timestamp: 1791152169
X-Evictions-Signature: 9f2c5e0a... (64 lowercase hex characters){
"id": "evt_fe8bc3a6-fd3a-4199-bb5a-1ef652b5cbac",
"type": "case.submit",
"caseId": "case_e2d1a3fa-830d-42e1-acb3-58922df3393b",
"state": "submitted",
"createdAt": "2026-10-04T22:16:09.280Z"
}| Field | Type | Description |
|---|---|---|
id | string | The event’s id. The same event has the same id on every delivery attempt, so use it to ignore one you have already handled. |
type | string | case. followed by the name of the event, such as case.submit. The names are listed below. |
caseId | string | The case the event belongs to. Fetch it with GET /v1/cases/{id}. |
state | string | The case’s state after the event. For an event that does not change the state, its current state. |
createdAt | string | When the event happened, as an ISO 8601 UTC time. |
Event types
Most events are the case moving from one state to the next, named for the step:
case.submitcase.assign_attorneycase.begin_reviewcase.request_changescase.approve_noticecase.send_noticecase.notice_servedcase.start_cure_periodcase.curecase.close_resolvedcase.cure_period_expiredcase.approve_filingcase.submit_filingcase.filing_rejectedcase.rework_filingcase.filing_acceptedcase.send_summonscase.summons_servedcase.schedule_hearingcase.record_judgmentcase.request_writcase.writ_issuedcase.closecase.withdrawcase.holdcase.resume
Two more are recorded without a state change:
case.cure_reportedcase.service_failed
Events that are internal to the platform are never delivered. The same customer-visible events are in the case’s history at GET /v1/cases/{id}/events.
Verify the signature
The X-Evictions-Timestamp header is the time of the delivery, in whole seconds since the Unix epoch. The X-Evictions-Signature header is the lowercase hex of an HMAC-SHA256 of the string ${timestamp}.${body}, keyed with your endpoint’s secret: the timestamp, a dot, then the request body exactly as it arrived. Compute it yourself and compare in constant time.
- Use the raw body. A body that has been parsed and written out again may differ by a space, and then the signature will not match.
- Check the timestamp too. A valid signature does not expire on its own, so refuse a delivery whose timestamp is more than a few minutes from your clock, as the example does. Retries are signed again with a fresh timestamp.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// secret: the whsec_... value returned once when you registered the endpoint
// rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing
// headers: the request headers (Node lowercases the names)
export function verifyEvictionsWebhook(secret, rawBody, headers) {
const timestamp = headers['x-evictions-timestamp'];
const signature = headers['x-evictions-signature'];
if (typeof timestamp !== 'string' || typeof signature !== 'string') return false;
// Refuse old deliveries, so a captured request cannot be replayed later.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(ageSeconds <= TOLERANCE_SECONDS)) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const given = Buffer.from(signature);
const wanted = Buffer.from(expected);
return given.length === wanted.length && timingSafeEqual(given, wanted);
}
import { createServer } from 'node:http';
import { verifyEvictionsWebhook } from './verify.js';
const SECRET = process.env.EVICTIONS_WEBHOOK_SECRET;
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
if (req.method !== 'POST' || !verifyEvictionsWebhook(SECRET, rawBody, req.headers)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody);
// Answer with a 2xx straight away and do the work afterwards.
// The same event.id can arrive more than once, so ignore one you have already handled.
res.writeHead(200).end();
console.log(event.type, event.caseId, event.state);
});
}).listen(3000);
Responses and retries
Answer with any 2xx status within 10 seconds and the delivery counts as done. Anything else counts as a failure: a non-2xx status, a redirect (it is not followed), a refused connection or a timeout. A failed delivery is tried up to five tries in all, spaced like this:
| Try | When |
|---|---|
| 1 | Straight away, when the event happens. |
| 2 | 1 minute after the first try failed. |
| 3 | 5 minutes after the second. |
| 4 | 30 minutes after the third. |
| 5 | 120 minutes after the fourth. If this one fails too, the delivery is given up. |
That is waits of 1, 5, 30 and 120 minutes. Retries run on a background worker that wakes every 30 seconds, so one can arrive up to about half a minute after its time. After the fifth failed try the API stops; read GET /v1/cases/{id} to catch up.
Because of retries, treat deliveries as at least once, and do not assume they arrive in order: a retry of an earlier event can land after a later one. Dedupe on id, and when the order matters, read the case rather than trusting the last event you saw.
The quickstart registers an endpoint as its last step, and the API reference has the endpoint itself.