Webhooks
We deliver every event on your orders to one HTTPS endpoint of yours, signed, so
you do not have to poll. Everything we deliver is also readable from
GET /events, which is how you catch up after an outage.
Subscribing
Set your endpoint with PUT /webhook:
curl -s -X PUT https://api.appraisalhost.com/v1/webhook \
-H 'Authorization: Bearer aht_at_7Qk2rX9wTm4ZbN1sV6yH0pL8' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://los.example.com/hooks/appraisal-host",
"enabled": true,
"events": ["*"],
"secret": "example_webhook_secret_replace_this_with_32_random_characters"
}'
{
"url": "https://los.example.com/hooks/appraisal-host",
"enabled": true,
"events": ["*"],
"secret_set": true,
"secret_rotated_at": "2026-09-17T14:45:00Z",
"updated_at": "2026-09-17T14:45:00Z"
}
- The URL must be HTTPS and must answer a POST with any 2xx within 10 seconds.
PUT /webhookreplaces the fields you send and leaves the others as they are. Leaveeventsout and your subscription is unchanged. Leavesecretout and your signing secret is unchanged: this call never clears a secret. Send"enabled": falseto stop delivery without losing your configuration.urlis always required.secretsets or rotates the signing secret. Use at least 32 characters of random text. We store it in a form we can sign with and never display it again, so keep your own copy.eventssubscribes you to a subset, or["*"]for all of them. Unsubscribed events are still raised and still readable atGET /events: we simply do not deliver them, and their delivery status isnot_subscribed.GET /webhookreturns the current configuration. The secret is never returned.POST /webhook/testsends you one signed specimen event and tells you what your endpoint answered. Use it after any change here.- The appraisal management company can also set your endpoint from its own screens. The last write wins, whichever side made it.
Rotating the secret. One secret is in force at a time, and every attempt is signed with the secret in force when that attempt is made. So a retry of an older event, sent after you rotate, carries the new secret rather than the one in force when the event was raised. Change both sides together, or accept both the old and the new signature for a short window and then drop the old one.
What a delivery looks like
POST /hooks/appraisal-host HTTP/1.1
Host: los.example.com
Content-Type: application/json
X-AH-Event-Id: evt_01M2T9W27R459H4DHVZGW4WNSS
X-AH-Event-Type: order.status_changed
X-AH-Signature: t=1789736654,v1=3b1f8c0a7d2e5f49b6c8a1d3e5f709b2c4d6e8fa1b3c5d7e9f0a2b4c6d8e0f2a4
User-Agent: AppraisalHost-Webhooks/1.0
Every body carries the same envelope:
{
"id": "evt_01M2T9W27R459H4DHVZGW4WNSS",
"type": "order.status_changed",
"created_at": "2026-09-18T13:04:11Z",
"api_version": "1.0.0",
"data": {
"order_id": "ord_9TBK4C2QFA7M",
"order_number": "2026-1043",
"status": { "code": "appraiser_assigned", "label": "Appraiser Assigned", "changed_at": "2026-09-18T13:04:11Z" },
"previous_status": { "code": "new", "label": "New Order" }
}
}
idis the event id, the same value asX-AH-Event-Id.typetells you what happened. Branch on it.created_atis when we raised the event, not when we delivered it. A redelivery keeps the originalcreated_at.tinX-AH-Signatureis when we signed this attempt. A retry hours later carries a freshtand the originalcreated_at, which is why the five minute tolerance is checked againsttand never againstcreated_at.api_versionis the contract version the payload was built to.testis present andtrueonly on a specimen event sent byPOST /webhook/test. It is absent orfalseon every real event. A specimen carries a syntheticorder_id, always prefixedord_test_, which no real order in any environment ever carries, so it can never match one of your orders. It is not written toGET /events, and no order changed. Treat it as a delivery test: verify it, answer200, and stop. This is true in production as well as in the sandbox, becausePOST /webhook/testworks in both.dataalways carriesorder_idandorder_number, plus the fields for that event type.
Verifying the signature
X-AH-Signature carries two parts: t, the Unix time in seconds when we signed
the delivery, and v1, a hex encoded HMAC-SHA256 over the string
<t>.<raw request body>, keyed with your webhook secret.
Three rules:
- Sign the raw body bytes, exactly as received. Do not parse the JSON and re-serialise it first: any change in spacing or key order changes the signature.
- Compare with a constant time comparison.
- Reject a delivery whose
tis more than five minutes from your own clock, so that a captured delivery cannot be replayed at you later. Keep your server clock in sync, and useGET /pingto see ours.
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(
piece.split("=", 1)
for piece in signature_header.split(",")
if "=" in piece
)
timestamp = parts.get("t", "")
sent = parts.get("v1", "")
if not timestamp.isdigit() or not sent:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sent)
Node.js
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300;
function verify(rawBody, signatureHeader, secret) {
const parts = {};
for (const piece of String(signatureHeader).split(',')) {
const index = piece.indexOf('=');
if (index > 0) parts[piece.slice(0, index)] = piece.slice(index + 1);
}
const timestamp = parts.t;
const sent = parts.v1;
if (!/^\d+$/.test(timestamp || '') || !sent) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(timestamp + '.'), rawBody]))
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sent, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Reject an unverified delivery with 401. We treat that as a failed attempt and
retry, which is what you want while a secret rotation is half finished.
Answering a delivery
- Return any 2xx within 10 seconds. Acknowledge first, then do your work in a queue of your own. A slow handler is the most common cause of a retry storm.
- Any other status, a timeout, or a connection failure counts as a failed attempt.
- Do not return a 2xx for a delivery you could not verify or could not store.
A failure is better than a silently dropped event: we will retry, and the
event stays visible at
GET /eventseither way.
Retries and dead letters
We make up to nine attempts in all: the first when the event is raised, then eight retries with growing gaps, over about 24 hours.
| Attempt | Sent after |
|---|---|
| 1 | The first delivery, when the event is raised |
| 2 | 1 minute after attempt 1 |
| 3 | 5 minutes after attempt 2 |
| 4 | 30 minutes after attempt 3 |
| 5 | 2 hours after attempt 4 |
| 6 | 4 hours after attempt 5 |
| 7 | 6 hours after attempt 6 |
| 8 | 6 hours after attempt 7 |
| 9 | 11 hours after attempt 8 |
The last attempt lands about 24 hours and 35 minutes after the event is raised.
Delivery is at least once, and every event we raise is also readable from
GET /events. Deliveries are not timed guarantees: build on the event as the
trigger, the resource as the truth, and a scheduled reconciliation as the
backstop.
After it the event is dead lettered: we stop trying, the appraisal management
company is notified, and the event stays readable at GET /events with
"status": "dead_lettered". Nothing is lost and there is nothing to ask us to
resend. When you see a dead letter, read the feed from the last event id you
processed and carry on from there.
Ordering and idempotency
Two properties to build for, because both will happen:
- Deliveries can arrive out of order. A retry of an earlier event can land
after a later one. Use
created_at, and thestatus.changed_atinside the payload, to decide what is newest. Never treat the arrival order as the truth, and do not move an order backwards because an old event arrived late. - A change you cannot see raises nothing. Work moves inside the appraisal
management company between statuses the lender does not see. Those changes
raise no event. You may also receive an
order.status_changedwhosestatusis the one you already hold: treat it as a no-op. - Deliveries can repeat. A redelivery carries the same
id. Record the ids you have processed and ignore a repeat. If you need a single simple rule: make your handler idempotent onid, and make your order state a function of the payload rather than a counter you increment.
When an event tells you something changed and you need the whole picture, read
GET /orders/{order_id}. The event is the trigger; the resource is the truth.
The catch-up feed
GET /events returns the events we raised for your client, oldest first, each
with its delivery state. This is how you recover from a listener outage without
calling anyone.
curl -s 'https://api.appraisalhost.com/v1/events?after=evt_01M2T9W27R459H4DHVZGW4WNSS&per_page=100' \
-H 'Authorization: Bearer aht_at_7Qk2rX9wTm4ZbN1sV6yH0pL8'
{
"data": [
{
"id": "evt_01M3Q5WZA8V0AB3B4S4B8E3XC5",
"type": "order.completed",
"created_at": "2026-09-29T18:12:45Z",
"order_id": "ord_9TBK4C2QFA7M",
"data": {
"order_id": "ord_9TBK4C2QFA7M",
"order_number": "2026-1043",
"status": { "code": "report_complete", "label": "Report Complete", "changed_at": "2026-09-29T18:12:44Z" },
"report_format": "uad_3_6",
"revision": false,
"completed_at": "2026-09-29T18:12:44Z",
"documents": [
{ "id": "doc_3XH8M1PLQW60", "kind": "report_pdf", "filename": "report_2026-1043.pdf", "content_type": "application/pdf", "size_bytes": 2841773, "created_at": "2026-09-29T18:12:44Z" }
]
},
"delivery": {
"status": "failed",
"attempts": 3,
"last_attempt_at": "2026-09-29T18:48:02Z",
"next_attempt_at": "2026-09-29T20:48:02Z",
"last_response_status": 502
}
}
],
"next_cursor": "evt_01M3Q5WZA8V0AB3B4S4B8E3XC5"
}
- The payload under
datais the same payload we deliver to your endpoint for that event, without the envelope fields. Its schema is the one published underwebhooksin the contract for thattype. - Store the id of the last event you processed. Pass it as
afterand keep reading whilenext_cursoris not null. - Event ids sort in the order the events were raised, so reading forward from your last id cannot skip anything.
- Filter with
type,order_idordelivery_statuswhen you are hunting a specific problem. - Events are retained for 30 days. A gap longer than that is reconciled
from the orders themselves with
GET /orders?updated_since=....
The event catalog
type | Raised when | Key fields in data |
|---|---|---|
order.created | An order is created for the lender account, including orders placed in the portal by the lender's own staff. | order_type, status, due_date, lender_reference |
order.status_changed | The status the lender sees changes. | status, previous_status, note |
order.assigned | An appraiser is assigned. The event always fires; appraiser is null where the company does not share the assigned appraiser with its lenders. | appraiser, assigned_at |
order.on_hold | The order is put on hold. | status, reason |
order.resumed | The order comes off hold. | status, reason |
order.cancelled | The order is cancelled. | status, reason |
order.completed | A report is delivered, and again for a revised report. | report_format, revision, completed_at, documents |
order.document_added | A document the lender may see is added. | document |
order.message_posted | The company or the appraiser posts a message on the order. | message |
order.revision_requested | A correction or reconsideration is opened. | revision_request |
order.revision_responded | A revision request is accepted, declined or answered. | revision_request_id, status, response |
order.due_date_changed | The due date on the order changes. | due_date, previous_due_date, requested_by |
order.fee_changed | The fee the lender is charged changes. | appraisal_fee, previous_appraisal_fee, currency, reason |
Every event has a typed payload and a worked example in the machine readable
contract at
/developers/spec/openapi.json,
under webhooks.
Two more payloads in full
order.assigned, which carries the appraiser's display name and nothing else
about them. The name is present only where the appraisal management company
shares the assigned appraiser with its lenders; where it does not, appraiser is
null and the event still fires:
{
"id": "evt_01M2T9W27RXJWVPHSK03CJAJQH",
"type": "order.assigned",
"created_at": "2026-09-18T13:04:11Z",
"api_version": "1.0.0",
"data": {
"order_id": "ord_9TBK4C2QFA7M",
"order_number": "2026-1043",
"appraiser": { "name": "J. Marsh" },
"assigned_at": "2026-09-18T13:04:11Z"
}
}
order.document_added, which is how later files reach you:
{
"id": "evt_01M3Q5WZA8WNXQQRS6N3X84JWZ",
"type": "order.document_added",
"created_at": "2026-09-29T18:12:45Z",
"api_version": "1.0.0",
"data": {
"order_id": "ord_9TBK4C2QFA7M",
"order_number": "2026-1043",
"document": {
"id": "doc_8QL4P9WMYC72",
"kind": "invoice",
"filename": "invoice_2026-1043.pdf",
"content_type": "application/pdf",
"size_bytes": 88120,
"created_at": "2026-09-29T18:12:45Z"
}
}
}
A handler worth copying
- Read the raw body.
- Verify the signature and the timestamp. Reject with
401if either fails. - If
testis true, return200and stop. Do not store it, and do not look the order up: a specimen event refers to no order in any environment. - Look up
id. If you have it, return200and stop. - Write the event to your own queue or table.
- Return
200. - Process asynchronously: branch on
type, and readGET /orders/{order_id}when you need the full picture. - Once a day, walk
GET /events?after=<last processed id>andGET /orders?updated_since=<yesterday>to prove you missed nothing.