Appraisal Host - Appraisal Management Software for AMCs and Lenders

Developer documentation

OpenAPI 3.1 spec

Guide

Appraisal Host APIAuthenticationSandboxOrder lifecycleWebhooksReference notesChangelogSupport

API reference

Every endpoint

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 /webhook replaces the fields you send and leaves the others as they are. Leave events out and your subscription is unchanged. Leave secret out and your signing secret is unchanged: this call never clears a secret. Send "enabled": false to stop delivery without losing your configuration. url is always required.
  • secret sets 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.
  • events subscribes you to a subset, or ["*"] for all of them. Unsubscribed events are still raised and still readable at GET /events: we simply do not deliver them, and their delivery status is not_subscribed.
  • GET /webhook returns the current configuration. The secret is never returned.
  • POST /webhook/test sends 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" }
  }
}
  • id is the event id, the same value as X-AH-Event-Id.
  • type tells you what happened. Branch on it.
  • created_at is when we raised the event, not when we delivered it. A redelivery keeps the original created_at.
  • t in X-AH-Signature is when we signed this attempt. A retry hours later carries a fresh t and the original created_at, which is why the five minute tolerance is checked against t and never against created_at.
  • api_version is the contract version the payload was built to.
  • test is present and true only on a specimen event sent by POST /webhook/test. It is absent or false on every real event. A specimen carries a synthetic order_id, always prefixed ord_test_, which no real order in any environment ever carries, so it can never match one of your orders. It is not written to GET /events, and no order changed. Treat it as a delivery test: verify it, answer 200, and stop. This is true in production as well as in the sandbox, because POST /webhook/test works in both.
  • data always carries order_id and order_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:

  1. 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.
  2. Compare with a constant time comparison.
  3. Reject a delivery whose t is 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 use GET /ping to 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 /events either 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.

AttemptSent after
1The first delivery, when the event is raised
21 minute after attempt 1
35 minutes after attempt 2
430 minutes after attempt 3
52 hours after attempt 4
64 hours after attempt 5
76 hours after attempt 6
86 hours after attempt 7
911 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 the status.changed_at inside 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_changed whose status is 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 on id, 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 data is the same payload we deliver to your endpoint for that event, without the envelope fields. Its schema is the one published under webhooks in the contract for that type.
  • Store the id of the last event you processed. Pass it as after and keep reading while next_cursor is 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_id or delivery_status when 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

typeRaised whenKey fields in data
order.createdAn 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_changedThe status the lender sees changes.status, previous_status, note
order.assignedAn 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_holdThe order is put on hold.status, reason
order.resumedThe order comes off hold.status, reason
order.cancelledThe order is cancelled.status, reason
order.completedA report is delivered, and again for a revised report.report_format, revision, completed_at, documents
order.document_addedA document the lender may see is added.document
order.message_postedThe company or the appraiser posts a message on the order.message
order.revision_requestedA correction or reconsideration is opened.revision_request
order.revision_respondedA revision request is accepted, declined or answered.revision_request_id, status, response
order.due_date_changedThe due date on the order changes.due_date, previous_due_date, requested_by
order.fee_changedThe 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

  1. Read the raw body.
  2. Verify the signature and the timestamp. Reject with 401 if either fails.
  3. If test is true, return 200 and stop. Do not store it, and do not look the order up: a specimen event refers to no order in any environment.
  4. Look up id. If you have it, return 200 and stop.
  5. Write the event to your own queue or table.
  6. Return 200.
  7. Process asynchronously: branch on type, and read GET /orders/{order_id} when you need the full picture.
  8. Once a day, walk GET /events?after=<last processed id> and GET /orders?updated_since=<yesterday> to prove you missed nothing.
Order lifecycleReference notes
Appraisal Host - Appraisal Management Software for AMCs and Lenders

Platform

  • Features
  • Integrations
  • Pricing

Solutions

  • For AMCs
  • For Lenders
  • For Appraisal Companies
  • For Non-QM Lenders
  • For Banks
  • For Credit Unions

Resources

  • Tools
  • Developers
  • Blog
  • FAQ
  • Support
  • Compliance & Regulations

Company

  • About
  • Contact

Appraisal Host

1 Washington Mall #1105

Boston, MA 02108

© 2026 Appraisal Host

|Privacy Policy|Terms of Service|SMS Consent

appraisalhost.com is a service of Appraisal Host LLC.

Powered byOptiWork.ai