Webhooks

A price alert watches one provider's price for one model and POSTs a signed JSON body to your endpoint when that price changes.

Create an alert

  1. Open the dashboard.
  2. Choose a provider, then a model.
  3. Enter your endpoint URL. It must be https.
  4. Copy the signing secret. It is shown on creation and stays readable on the alert's row.

Limit: 10 alerts per account. Alerts are created in the dashboard only. No endpoint creates, lists or deletes one, and /api/v1 stays read-only.

Events

One event type, price.changed, in three kinds:

kindSent whenold
changedA published price for the listing moves.The previous values.
createdThe provider starts listing that model.null
restoredThe provider lists it again after dropping it.The values held before it was dropped.

Watched fields: input, output, cache_read, cache_write, reasoning, context, unit. A rename, a new tag, or a re-read that returns the same number is not a change and sends nothing.

Sources are polled every five minutes. A move large enough to look like a parsing error is held until a second reading confirms it, so a webhook arrives once, after the price is published, and never twice for the same change.

The request

Request
POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: indextkn-webhooks/1
HeaderValue
X-Indextkn-Eventprice.changed
X-Indextkn-DeliveryDelivery id, same as id in the body. Stable across retries. Key your idempotency on it.
X-Indextkn-TimestampUnix seconds at signing time.
X-Indextkn-Signaturet=<timestamp>,v1=<hex>. See Verify the signature.

Payload

{
  "id": "0f2b6c48-1f7e-4a02-9a1e-8b0c0f0f0f0f",
  "type": "price.changed",
  "kind": "changed",
  "created_at": "2026-08-31T12:04:07.000Z",
  "alert": {
    "id": "3a9f2c10-7d61-4f5e-9a2b-1c2d3e4f5a6b",
    "provider": "anthropic",
    "model": "anthropic/claude-opus-4.5"
  },
  "model": {
    "id": "anthropic/claude-opus-4.5",
    "lab": "anthropic",
    "name": "Claude Opus 4.5",
    "modality": "text"
  },
  "provider": "anthropic",
  "provider_model_id": "claude-opus-4-5",
  "changed": ["input", "cache_read"],
  "old": {
    "name": "Claude Opus 4.5",
    "input": 5,
    "output": 25,
    "cache_read": 0.5,
    "cache_write": 6.25,
    "reasoning": null,
    "unit": "1M_tokens",
    "context": 200000,
    "max_output": 64000,
    "status": "accurate"
  },
  "new": {
    "name": "Claude Opus 4.5",
    "input": 4.5,
    "output": 25,
    "cache_read": 0.45,
    "cache_write": 6.25,
    "reasoning": null,
    "unit": "1M_tokens",
    "context": 200000,
    "max_output": 64000,
    "status": "checking"
  },
  "source_url": "https://www.anthropic.com/pricing",
  "observed_at": "2026-08-31T12:04:07.000Z"
}
FieldTypeExampleDescription
idstring0f2b6c48-...Delivery id. Same value as the X-Indextkn-Delivery header, and stable across retries.
typestringprice.changedEvent type. Only one exists today.
kindstringchangedchanged, created or restored. See Events.
created_atstring2026-08-31T12:04:07.000ZISO 8601 UTC. When the change was observed.
alert.idstring3a9f2c10-...The alert this delivery belongs to.
alert.providerstringanthropicProvider the alert watches.
alert.modelstringanthropic/claude-opus-4.5Model the alert watches.
model.idstringanthropic/claude-opus-4.5Canonical model id, lab/model.
model.labstringanthropicWho built the model.
model.namestringClaude Opus 4.5Display name.
model.modalitystringtexttext, image, audio, embedding, rerank, and so on.
providerstringanthropicWho sells it. Same as alert.provider.
provider_model_idstringclaude-opus-4-5The provider's own id for the model.
changedstring[]["input"]Which fields moved. Empty only on a restore that changed nothing.
oldobject | nulln/aEvery value held before the change. null when kind is created.
newobjectn/aEvery value held after it.
source_urlstringhttps://www.anthropic.com/pricingThe provider page or endpoint the price was read from.
observed_atstring2026-08-31T12:04:07.000ZSame as created_at. Present so the body reads on its own.

old and new

The same shape on both sides. old is every value held before the change, new is every value held after it, and changed names the difference. Prices are USD per unit. There is no history in the payload: the two states either side of this change, nothing older.

FieldTypeExampleDescription
namestringClaude Opus 4.5The provider's name for the listing.
inputnumber | null4.5USD per unit for input tokens. null when not published.
outputnumber | null25USD per unit for output tokens.
cache_readnumber | null0.45USD per unit for cached input tokens.
cache_writenumber | null6.25USD per unit to write the cache.
reasoningnumber | nullnullUSD per unit for reasoning tokens, where billed apart from output.
unitstring1M_tokensWhat the prices are per: 1M_tokens, 1M_chars, image, second, hour, request.
contextnumber | null200000Context window, in tokens, as this provider serves it.
max_outputnumber | null64000Max output tokens, as this provider serves it.
statusstringaccurateConfidence in this price. See Confidence.

Confidence

old.status and new.status carry the accuracy status each price held when it was published, the same values /api/v1 puts on an offer. The old price keeps the confidence it was published with, so a delivery says both how far the number you were using was trusted and how far its replacement is.

statusMeaning
accurateNo open doubt about the number.
checkingA web verification of this price is in flight.
suspiciousAn open doubt: a large unconfirmed move, a cross-provider outlier, or an inverted input/output pair.
partialThe number is not in doubt, but the provider publishes only one price leg, so a total costed from it comes out short.
failedA web check ran and could not confirm the price.

Status is not one of the watched fields: a status moving on its own does not send a webhook. Read new.status before acting on a price automatically. suspicious and failed mean the number has not been confirmed.

Verify the signature

Compute the HMAC over the raw request bytes. Re-serializing parsed JSON changes the bytes and will not match.

Signature
X-Indextkn-Signature: t=1756642, v1=8a1f...c2

  signed_payload = "<t>" + "." + "<raw request body>"
  v1             = hex(hmac_sha256(alert_secret, signed_payload))
  1. Read t and v1 from the header.
  2. Reject the request if t is more than five minutes old.
  3. Compute hmac_sha256(secret, t + "." + rawBody) as hex.
  4. Compare it to v1 in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the exact bytes we sent, before any JSON parsing.
export function verify(rawBody, signature, secret) {
  const parts = Object.fromEntries(
    signature.split(",").map((p) => p.split("=")),
  );
  // Reject anything older than five minutes: that is what the timestamp is for.
  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))
  );
}

Responses and retries

Your responseWhat happens
2xxDelivered. Nothing further is sent for that change.
429 or 5xxRetried up to 3 attempts in total, 0.5s then 1s apart. Same delivery id and same body each time.
Other 4xxNot retried. A 4xx says the request itself is wrong, so repeating it changes nothing.
3xxNot followed, counted as a failure. Point the alert at the final URL.
No responseTimed out after 10s and retried, same as a 5xx.

Endpoint requirements

  • https on port 443, no credentials in the URL.
  • A public host. A URL that resolves to a private, loopback or link-local address is refused, and the check is repeated immediately before every send.
  • Respond within 10 seconds. Do the work after you respond.
  • Treat X-Indextkn-Delivery as an idempotency key. A retry repeats it.

Failures

After 10 consecutive failed deliveries the alert is disabled and stops firing. The dashboard shows it as disabled with the last error. Resuming it there clears the counter and starts delivery again. A single successful delivery also resets the counter.

Delivery log

Every attempt is recorded against your account: the time, the listing, the fields that changed, the URL it went to, the exact body sent, and the status your endpoint returned. The dashboard shows the last 30 days under Webhook log. Deleting an alert deletes its log with it.

API Docs · indextkn