Webhooks
Register an HTTPS callback and Feathr POSTs a signed event to it when a person changes — no polling required.
Events
| Event | Fires when |
|---|---|
person.created | A person is created |
person.updated | An existing person's attributes or tags change |
Changes to the same person inside a short window — currently about five minutes — are coalesced into a single delivery, so one create-then-tag request delivers once. The delivery is held until the window closes, so expect roughly that much lag rather than a real-time push, and the window slides: each further change to the same person restarts it, so a person edited every four minutes keeps deferring.
One burst resolves to one event
Coalescing picks one event name for the whole burst, decided by how the
burst started. A burst that began with the person being created is delivered
as person.created, however many edits followed inside the window. Those
edits do not also produce a person.updated.
Matching is exact: a subscription receives a delivery only when the resolved
event name is listed in its events. There is no hierarchy and no fallback —
person.updated does not stand in for person.created, and in normal
operation neither is delivered under the other's name.
Together those two rules have a consequence worth designing around:
a subscriber registered only for person.updated receives nothing at all
for edits made inside the window after a create. The whole burst resolves to
person.created, which that subscription does not match.
The same rule running the other way is what normally keeps you from receiving a
mislabelled event: when a burst resolves to person.updated, a subscriber
registered only for person.created receives nothing, rather than a create
arriving under the wrong name. (A burst whose start cannot be determined
resolves to person.updated, so a brand-new person can occasionally arrive
that way. Branching on the envelope's event field, and treating an unknown
person id as new, keeps that from mattering.)
So subscribe to both events if you want to see every change. A
subscription listing ["person.created", "person.updated"] matches whichever
way the burst resolves, and the envelope's event field tells you which it
was — branch on that field rather than on which subscription delivered it.
Subscribing
curl -X POST https://api.feathr.co/webhooks \
-H "Authorization: Bearer $FEATHR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "crm-sync",
"target_url": "https://hooks.example.com/feathr",
"events": ["person.created", "person.updated"]
}'
The response is the only place the full signing secret appears — store it now.
Later reads return a masked hint (whsec_…ab12). Lost it? POST /webhooks/{id}/rotate-secret issues a new one.
Deliveries already in flight when a rotation lands are signed with the previous
secret, so accept both the old and the new secret for a few minutes after
rotating. Rejecting one with a 4xx drops it for good — a 4xx is not
retried.
Target URLs must use HTTPS and resolve to a public address.
Requires the webhooks:write scope; reads require webhooks:read.
Headers
| Header | What it carries |
|---|---|
Feathr-Webhook-Id | The subscription this delivery belongs to. Use it to select the right signing secret when one endpoint serves several subscriptions. |
Feathr-Delivery-Id | The delivery id, matching id in the body. |
Feathr-Event | The event name, matching event in the body. |
Feathr-Timestamp | The unix time the body was signed; also the t in the signature. |
Feathr-Signature | t=<unix>,v1=<hex> — see below. |
Payload
{
"id": "9f2c1e7b4a8d4f6ea1c05b3d7e9f2a84",
"event": "person.updated",
"created": "2026-08-01T12:00:00Z",
"data": {
"id": "abc123",
"external_id": null,
"first_name": "Ada",
"last_name": "Lovelace",
"name": "Ada Lovelace",
"phone": null,
"language": null,
"pseudonym": null,
"occupation": null,
"companies": [],
"address": {
"country_name": "United Kingdom",
"administrative_area_name": null,
"locality": "London",
"premise1": null,
"premise2": null,
"postal_code": "NW1 6XE",
"lat": 51.5237,
"lng": -0.1585,
"country_code": "GBR",
"country_code2": "GB",
"continent_code": "EU",
"region_name": null,
"timezone": "Europe/London",
"organization": null,
"administrative_area_code": null,
"sub_administrative_area_code": null,
"sub_administrative_area_name": null
},
"age": null,
"ethnicity": null,
"income": null,
"edu": null,
"tags": ["donor"],
"tag_ids": ["tag1"],
"email_opt_out": false,
"is_archived": false,
"upsert_result": "updated",
"0_person_url": "https://app.feathr.co/...",
"custom_fields": {"tier": "gold"},
"custom_data": {"tier": "gold", "lifetime_gifts": 0},
"date_created": "2026-07-14T09:31:02Z",
"date_last_seen": "2026-08-01T11:58:40Z"
}
}
data is the extended person view — the same shape
GET /persons/{id}?extended=true returns. Note that a plain
GET /persons/{id}, without extended=true, returns a smaller object: if
you build your field mapping by sampling that read, keys the delivery always
carries will be missing from it.
Every key above is always present. A value the person does not hold is null,
[] or {}, never absent. Note 0_person_url — the leading digit is
deliberate and is what goes on the wire, so quote it rather than destructuring
it by name.
custom_fields and custom_data
Two keys carry custom fields and they do not mean the same thing.
custom_fieldsholds only the values the person actually has, exactly as stored — no type coercion. A numeric field arrives as"5"and a checkbox as"true"or"false", both as strings."false"is a non-empty string, soif data["custom_fields"]["opted_in"]:is true for a person who is opted out. Compare explicitly.custom_datais a deprecated superset kept for Zapier compatibility: it lists every custom field on the account, applies legacy type coercion to the values the person holds, and fills a placeholder where they hold none. Placeholders are re-evaluated on every render, so an unset date field differs between deliveries — do not diff oncustom_datato decide what changed.
Prefer custom_fields.
address
address is always an object, with all seventeen subfields present and the
ones the person has no value for set to null. It is never null and never
absent, so if (data.address) is always true — test a subfield instead.
The delivery id
id is a 32-character hex string, and identifies one logical delivery rather
than one attempt: a retry of the same delivery — a timeout or a 5xx
response, redelivered with backoff — carries this same id and the matching
Feathr-Delivery-Id header, while a distinct event gets a distinct one. Use it
as an idempotency key to drop duplicates, and size the column for 32
characters: a truncating store would collapse distinct deliveries into one.
Verifying the signature
Each request carries Feathr-Signature: t=<unix>,v1=<hex>, where the digest is
HMAC-SHA256 over "<t>.<raw body>" using your signing secret.
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, max_age: int = 300) -> bool:
try:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp, received = parts["t"], parts["v1"]
if abs(time.time() - int(timestamp)) > max_age:
return False
except (ValueError, KeyError):
# A malformed header is a failed verification, not an exception. Letting
# it raise turns into a 500, which is retried with backoff — so a probe
# sending junk would have Feathr amplify it against your endpoint.
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received)
Compare against the raw body, before any JSON parsing or re-serialization.
verify rejects any delivery whose t is more than max_age seconds from
the current time, in either direction, to blunt replays while tolerating
clock skew.
Retries and failures
A 2xx marks the delivery successful. Timeouts and 5xx responses retry with
exponential backoff. A 4xx is recorded as a failure without a retry.
Redirects are never followed, and a 3xx response is treated the same way as
a 4xx: recorded as a failure without a retry, because the payload never
actually reached your endpoint. Point target_url at the final URL your
service serves — not a redirector.
Sustained consecutive failures deactivate the subscription — check
is_active and last_error on GET /webhooks/{id}.
Respond 410 Gone to unsubscribe: the subscription is retired immediately.