Webhooks
Webhooks are how Humind notifies your backend when something interesting happens, without you having to poll. You declare an HTTPS endpoint on your side, subscribe it to one or more event types via POST /public/v1/webhooks/endpoints, and Humind sends a signed JSON payload to that URL every time a matching event fires.
The available events are import.completed and import.failed, so you can stop polling GET /imports/{sync_id} once a bulk import is in flight.
Quickstart
The whole loop, end to end:
1. POST /webhooks/endpoints → { id, secret } (one-time, copy the secret)
2. <event happens at Humind> → POST {your_url} with X-Humind-Signature
3. Verify signature, ack with 2xx → done; non-2xx triggers retryThree steps to wire it up:
- Create an endpoint with
POST /public/v1/webhooks/endpoints. The response includes a one-timesecret: store it in your secret manager right now, you won't see it again. - Receive deliveries at the URL you registered. Humind sends a JSON
POSTwith the event-specific payload, plus signature headers. - Verify the signature before trusting the payload, ack with any
2xx. Non-2xx (or no response within 10 seconds) triggers automatic retries.
Step 1: Create an endpoint
POST /public/v1/webhooks/endpoints registers a destination URL and a list of event types you want to receive.
Required scope: webhooks:manage
Request
curl -X POST https://api.thehumind.com/public/v1/webhooks/endpoints \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 7c1a8d92-3f4b-4e6f-b2a1-d5e9c8f7a3b4" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.merchant.example/humind-webhooks",
"events": ["import.completed", "import.failed"]
}'| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS URL Humind will POST to. Must be https:// and publicly reachable. http:// and private hostnames (e.g. localhost, 127.0.0.1) are rejected with invalid_url in live mode. |
events | string[] | Yes | List of event types to subscribe to. See Available events. At least one. Unknown event types return invalid_event_type. |
Response 201 Created
{
"id": "whe_5f9e3a2b7c1d4e8a9b3f0c2d",
"url": "https://api.merchant.example/humind-webhooks",
"events": ["import.completed", "import.failed"],
"status": "active",
"prefix": "whsec_5f9e3a2b7c1d4e8a9b3f0c",
"secret": "whsec_5f9e3a2b7c1d4e8a9b3f0c2d4e5f6a7b_a1b2c3d4",
"failure_count": 0,
"last_delivered_at": null,
"last_failed_at": null,
"created_at": "2026-04-25T14:30:00Z"
}The signing secret is shown only once
The full secret is returned only by this POST and by POST /rotate-secret. GET calls return prefix (so you can identify which secret is in use) but never the full value. Store it in your secret manager, Vault, AWS Secrets Manager, Azure Key Vault, Doppler, before you do anything else. If you lose it, you'll need to rotate and update both sides.
The prefix is the first 22 characters of the secret. You can show it in your dashboard or logs without leaking the secret itself.
Step 2: Receive deliveries
When a subscribed event fires, Humind sends an HTTPS POST to your url with these headers:
| Header | Example | Purpose |
|---|---|---|
Content-Type | application/json | Body is always JSON. |
X-Humind-Event-Type | import.completed | Which event fired. Same value as the event_type in the body. |
X-Humind-Event-Id | evt_8f3a1c2d4e5b6a7f1e2d3c4b | Unique per event. Use it to dedupe, see Idempotency. |
X-Humind-Timestamp | 1745595600 | Unix seconds when the delivery was generated. Used to verify the signature and reject replays. |
X-Humind-Signature | t=1745595600,v1=8f3a1c2d4e... | HMAC-SHA256 signature of <timestamp>.<raw_body>. See Step 3. |
Body
The body is JSON. The exact shape depends on the event type, see Available events.
{
"event_id": "evt_8f3a1c2d4e5b6a7f1e2d3c4b",
"event_type": "import.completed",
"created_at": "2026-04-25T14:41:55Z",
"data": {
"sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
"status": "done",
"resource_type": "product",
"total_products": 12000,
"synced_products": 12000,
"report": { "created": 9876, "updated": 2100, "failed": 24 },
"error_logs_count": 24,
"started_at": "2026-04-25T14:31:12Z",
"completed_at": "2026-04-25T14:41:55Z"
}
}Response
Reply with any 2xx within 10 seconds. Anything else, non-2xx, timeout, connection refused, TLS error, counts as a failure and triggers the retry policy. The body of your response is ignored; an empty 200 OK is fine.
Reply fast, process async
Don't do heavy work synchronously inside the handler. Push the payload to a queue (Redis, SQS, Pub/Sub, an internal cron) and ack with 200 immediately. A handler that takes 8 seconds to write to your database is one network hiccup away from a timeout, and Humind retries on timeout, which means the same event lands twice.
Step 3: Verify the signature
Every delivery is signed. Before trusting the payload, your handler must:
- Read
X-Humind-Timestampand reject if it's outside a ±5 minute window vs your server clock (replay protection). - Compute
HMAC_SHA256(secret, "<timestamp>.<raw_body>")using the raw, unparsed request body. - Compare against the
v1=value inX-Humind-Signatureusing a constant-time comparison.
Verify before parsing the JSON or kicking off any side effect. The signature is what proves the payload came from Humind and wasn't tampered with, without it, anyone who finds your endpoint URL can post arbitrary data.
Node.js
import crypto from 'node:crypto'
function verifyHumindSignature(rawBody, headers, secret) {
const sigHeader = headers['x-humind-signature'] || ''
const tsHeader = headers['x-humind-timestamp'] || ''
// 1. Timestamp must be within +/- 5 minutes of now
const now = Math.floor(Date.now() / 1000)
const ts = parseInt(tsHeader, 10)
if (!ts || Math.abs(now - ts) > 5 * 60) return false
// 2. Recompute the HMAC over "<timestamp>.<raw_body>"
const signed = `${tsHeader}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex')
// 3. Extract the v1 component and compare in constant time
const provided = (sigHeader.split(',').find(p => p.startsWith('v1=')) || '').slice(3)
if (!provided || provided.length !== expected.length) return false
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex'),
)
}Use the raw body, not a re-serialized JSON object
Frameworks like Express parse the body before your handler runs. If you call JSON.stringify(req.body) to recompute the HMAC, the byte order of object keys or the exact whitespace can differ from what Humind signed and the check will fail. Capture the raw Buffer (Express: express.raw({ type: 'application/json' })) and pass that to the verifier.
Python
import hmac
import hashlib
import time
def verify_humind_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
sig_header = headers.get("X-Humind-Signature", "")
ts_header = headers.get("X-Humind-Timestamp", "")
# 1. Timestamp must be within +/- 5 minutes of now
try:
ts = int(ts_header)
except ValueError:
return False
if abs(int(time.time()) - ts) > 5 * 60:
return False
# 2. Recompute the HMAC over "<timestamp>.<raw_body>"
signed = f"{ts_header}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# 3. Extract the v1 component and compare in constant time
provided = ""
for part in sig_header.split(","):
if part.startswith("v1="):
provided = part[3:]
break
if not provided:
return False
return hmac.compare_digest(expected, provided)Ruby
require "openssl"
require "rack/utils"
def verify_humind_signature(raw_body, headers, secret)
sig_header = headers["X-Humind-Signature"].to_s
ts_header = headers["X-Humind-Timestamp"].to_s
# 1. Timestamp must be within +/- 5 minutes of now
ts = Integer(ts_header) rescue nil
return false if ts.nil? || (Time.now.to_i - ts).abs > 5 * 60
# 2. Recompute the HMAC over "<timestamp>.<raw_body>"
signed = "#{ts_header}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
# 3. Extract the v1 component and compare in constant time
provided = sig_header.split(",").find { |p| p.start_with?("v1=") }&.sub("v1=", "")
return false if provided.nil? || provided.empty?
Rack::Utils.secure_compare(expected, provided)
endThe Webhook Endpoint object
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier, prefixed whe_. Use it in the path of every subsequent call. |
url | string | HTTPS URL Humind delivers to. |
events | string[] | Event types this endpoint is subscribed to. |
status | 'active' | 'disabled' | Whether deliveries are currently being attempted. See Disabling vs deleting. |
prefix | string | First 22 characters of the signing secret. Safe to log; useful to confirm which secret is in use after a rotation. |
failure_count | integer | Number of consecutive failed deliveries since the last successful one. Resets to 0 on any successful delivery. |
last_delivered_at | ISO 8601 | null | When the last successful delivery completed. null if no delivery has succeeded yet. |
last_failed_at | ISO 8601 | null | When the most recent failed delivery happened. null if none have failed. |
disabled_reason | string | null | Set when the endpoint was auto-disabled. consecutive_failures. |
created_at | ISO 8601 | When the endpoint was registered. |
The full secret is returned only by POST /webhooks/endpoints and POST /webhooks/endpoints/:id/rotate-secret. GET returns prefix but never the full value.
The Delivery object
A delivery is one attempt to send one event to one endpoint. Every event creates one delivery row, which is updated in place as Humind retries.
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier, prefixed whd_. |
webhook_endpoint_id | string | The endpoint this delivery targeted. |
event_type | string | E.g. import.completed. |
event_id | string | Unique per event. Same value as X-Humind-Event-Id and the event_id in the body. |
status | 'pending' | 'delivered' | 'failed' | 'permanently_failed' | Lifecycle state. failed means at least one attempt failed but more are scheduled; permanently_failed means all 7 attempts were exhausted. |
attempts | object[] | One entry per attempt. Each contains attempted_at, status_code (or null if no response), response_time_ms, and an error string when the request couldn't even reach the merchant. |
next_attempt_at | ISO 8601 | null | When the next retry will fire. null once the delivery is delivered or permanently_failed. |
delivered_at | ISO 8601 | null | When a 2xx was first received. null until then. |
permanently_failed_at | ISO 8601 | null | When the delivery was given up on. null if not exhausted. |
created_at | ISO 8601 | When the event fired. |
Available events
| Event | Description | Payload |
|---|---|---|
import.completed | A bulk import reached done. Counts and report breakdown match the final GET /imports/{sync_id}. | Schema below |
import.failed | A bulk import reached failed (fatal failure during ingestion, not per-line validation). | Schema below |
import.completed
{
"event_id": "evt_8f3a1c2d4e5b6a7f1e2d3c4b",
"event_type": "import.completed",
"created_at": "2026-04-25T14:41:55Z",
"data": {
"sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
"status": "done",
"resource_type": "product",
"total_products": 12000,
"synced_products": 12000,
"report": {
"created": 9876,
"updated": 2100,
"failed": 24
},
"error_logs_count": 24,
"started_at": "2026-04-25T14:31:12Z",
"completed_at": "2026-04-25T14:41:55Z"
}
}The fields under data mirror the Import object. error_logs_count is the count, not the list, fetch GET /imports/{sync_id} if you need the most recent 100 entries.
import.failed
{
"event_id": "evt_9a4b2c3d5e6f7a8b9c0d1e2f",
"event_type": "import.failed",
"created_at": "2026-04-25T14:33:10Z",
"data": {
"sync_id": "c2e8f4a3-9d1b-4c7e-8a5f-1b3d4e5f6a7c",
"status": "failed",
"resource_type": "product",
"total_products": 12000,
"synced_products": 4321,
"report": {
"created": 3210,
"updated": 1100,
"failed": 11
},
"error_logs_count": 11,
"started_at": "2026-04-25T14:31:12Z",
"completed_at": "2026-04-25T14:33:10Z"
}
}import.failed covers fatal failures during ingestion (e.g. infrastructure outage): not per-line validation issues, which are reported in report.failed and error_logs regardless of whether the import as a whole reached done or failed.
Retry policy
If your endpoint returns anything other than 2xx, or doesn't respond within 10 seconds, Humind retries on this exponential schedule:
| Attempt | Delay since previous | Cumulative |
|---|---|---|
| 1 (initial) | 0 | 0 |
| 2 | 10 s | 10 s |
| 3 | 1 min | ~1 min |
| 4 | 5 min | ~6 min |
| 5 | 30 min | ~36 min |
| 6 | 2 h | ~2 h 36 min |
| 7 | 12 h | ~14 h 36 min |
| 8 | 24 h | ~38 h 36 min |
After 7 failed attempts (8 attempts total: 1 initial + 7 retries), the delivery is marked permanently_failed and Humind stops trying for this specific event. Other events keep flowing, one bad delivery doesn't disable the endpoint by itself.
If your endpoint accumulates 5 consecutive failed deliveries (any 5 events in a row, regardless of attempt count), the endpoint is auto-disabled: status flips to disabled, disabled_reason becomes consecutive_failures, and no new deliveries are queued. Re-enable it via PATCH /webhooks/endpoints/:id with { "status": "active" } once you've fixed the underlying issue.
failure_count resets to 0 on any successful delivery, so a single recovery is enough, you don't need to ack a backlog.
4xx is still a retry
Humind retries every non-2xx the same way, including 4xx. We don't try to be clever about "this looks like a permanent rejection", a 400 from a misdeployed handler followed by a fix should still get the event through. The only way to tell Humind to stop is to disable the endpoint.
Rotating the secret
POST /public/v1/webhooks/endpoints/:id/rotate-secret issues a new signing secret and immediately invalidates the old one.
Required scope: webhooks:manage
Request
curl -X POST https://api.thehumind.com/public/v1/webhooks/endpoints/whe_5f9e3a2b7c1d4e8a9b3f0c2d/rotate-secret \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 4d2e1f3a-5b6c-4d7e-8f9a-0b1c2d3e4f5a"Response 200 OK
{
"endpoint": {
"id": "5f9e3a2b7c1d4e8a9b3f0c2d",
"url": "https://api.merchant.example/humind-webhooks",
"events": ["import.completed", "import.failed"],
"prefix": "whsec_2a3b4c5d6e7f8a9b0c1d2e",
"checksum": "70cad189",
"status": "active",
"failure_count": 0,
"created_at": "2026-04-25T14:30:00Z",
"last_delivered_at": null,
"last_failed_at": null,
"disabled_at": null,
"disabled_reason": null
},
"secret": "whsec_2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d_e8f9a0b1",
"rotated_at": "2026-04-25T15:00:00Z"
}The endpoint block mirrors the shape of GET /webhooks/endpoints/:id so callers can introspect the post-rotation state (new prefix, refreshed failure_count, etc.) without a follow-up request. The full secret is at the top level — store it before discarding the response.
No grace period
The new secret takes effect immediately. Any delivery in flight (or scheduled to retry) starts being signed with the new secret as soon as the rotation completes. Update your handler's secret store before you rotate, once you click rotate, the old secret will start failing your verifier.
The recommended sequence:
- Add the new secret to your secret manager under a new version / pending slot.
- Update your handler to accept either the old or the new secret during a short window.
- Call
POST /rotate-secret. - Once a few deliveries have come in successfully on the new secret, remove the old one.
If you don't have a way to accept two secrets at once, you'll see verification failures for any delivery that arrives in the gap between the rotate API call and your deploy. The retry policy will recover them, but plan for it.
Disabling vs deleting an endpoint
Two ways to stop receiving deliveries to a URL:
| Action | Endpoint | Effect | Re-enable? |
|---|---|---|---|
| Disable | PATCH /webhooks/endpoints/:id with { "status": "disabled" } | No new deliveries queued. Existing deliveries are dropped. Endpoint stays in your list with its events, secret, and history. | Yes, PATCH with { "status": "active" }. |
| Delete | DELETE /webhooks/endpoints/:id | Endpoint removed permanently. Secret is invalidated. Past deliveries stay queryable for audit but no new ones are queued. | No, register a new endpoint instead. |
Use disable for a planned maintenance window, a paused integration, or to investigate a misbehaving handler without losing the configuration. Use delete when the endpoint is permanently gone (decommissioned service, migrated URL).
Auto-disabled endpoints (after 5 consecutive failures) are reactivated the same way, PATCH with { "status": "active" }. Humind doesn't try to re-deliver the events that piled up while disabled; you'll only get new ones from that point on.
Listing deliveries
GET /public/v1/webhooks/deliveries returns the recent deliveries for your company, with optional filters.
Required scope: webhooks:manage
Request
curl "https://api.thehumind.com/public/v1/webhooks/deliveries?endpoint_id=whe_5f9e3a2b7c1d4e8a9b3f0c2d&status=permanently_failed&limit=20" \
-H "Authorization: Bearer hmd_live_..."| Query param | Type | Description |
|---|---|---|
endpoint_id | string | Filter to deliveries for a single endpoint. |
status | 'pending' | 'delivered' | 'failed' | 'permanently_failed' | Filter by lifecycle status. |
limit | integer | How many deliveries to return. Default 20, max 100. |
Response 200 OK
{
"data": [
{
"id": "whd_a1b2c3d4e5f6a7b8c9d0e1f2",
"webhook_endpoint_id": "whe_5f9e3a2b7c1d4e8a9b3f0c2d",
"event_type": "import.completed",
"event_id": "evt_8f3a1c2d4e5b6a7f1e2d3c4b",
"status": "permanently_failed",
"attempts": [
{ "attempted_at": "2026-04-23T10:00:00Z", "status_code": 502, "response_time_ms": 312, "error": null },
{ "attempted_at": "2026-04-23T10:00:10Z", "status_code": 502, "response_time_ms": 280, "error": null }
],
"next_attempt_at": null,
"delivered_at": null,
"permanently_failed_at": "2026-04-25T00:00:10Z",
"created_at": "2026-04-23T10:00:00Z"
}
]
}GET /public/v1/webhooks/deliveries/:id returns a single delivery with the full attempt history and the original payload that was signed and sent.
Common errors
The Webhooks API can return any of the standard HTTP statuses, but these are the codes specific to this resource:
| Status | Code | When | Fix |
|---|---|---|---|
403 | insufficient_scope | Key lacks webhooks:manage. | Create a new key with webhooks:manage. |
404 | webhook_endpoint_not_found | The :id doesn't match an endpoint owned by your company. | Confirm the id. Cross-tenant lookups also return 404. |
404 | not_found | A delivery :id doesn't match anything for your company. | Confirm the id. |
422 | invalid_event_type | An entry in events isn't a known type. | Use one of the values listed under Available events. |
422 | invalid_url | url isn't a valid HTTPS URL, or points to a private host (localhost, 127.0.0.1, RFC1918 ranges) in live mode. | Use a publicly reachable https:// URL. For local development, use hmd_test_* keys with a tunnel (ngrok, Cloudflare Tunnel). |
400 | validation_failed | Body parsed but didn't match the schema (missing url, empty events, etc.). | Fix the field and resend. |
422 | webhook_disabled | An attempt to interact with a disabled endpoint in a way that requires it to be active. | Re-activate via PATCH /webhooks/endpoints/:id with { "status": "active" }. |
Best practices
- Dedupe on
event_id. Retries can deliver the same event more than once. TreatX-Humind-Event-Idas the idempotency key on your side: if you've already processed it, ack with2xxand skip. - Reply 2xx fast, process async. Push the payload to a queue and ack within ~1 second. A 9-second handler is one slow database query away from a timeout, and a timeout means a duplicate.
- Verify the signature before parsing. Run the HMAC check on the raw body before you
JSON.parseit. A request that fails verification should be rejected with401or400and not fed to the rest of your code. - Check the timestamp window. A valid signature on an old payload is still a replay. The ±5 minute window in the snippets above is what blocks it.
- Keep your healthcheck endpoint separate. Don't put your webhook receiver on the same URL as
/healthor your homepage, a temporary auth misconfig on the parent route could quietly start dropping events. - Never log the secret. Log the
prefix(it's safe by design) so you can confirm which version of the secret was in use, but treat the fullwhsec_*string the same way you treat your API key. - Use a tunnel for local development.
localhostis rejected in live mode byinvalid_url. With ahmd_test_*key you can registerhttps://your-id.ngrok.appand iterate locally without exposing a real URL.
Next
- Imports:
import.completedandimport.failedare the events that fire today. - Authentication: generate a
webhooks:managekey. - Errors: full error code reference.