Skip to content

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 retry

Three steps to wire it up:

  1. Create an endpoint with POST /public/v1/webhooks/endpoints. The response includes a one-time secret: store it in your secret manager right now, you won't see it again.
  2. Receive deliveries at the URL you registered. Humind sends a JSON POST with the event-specific payload, plus signature headers.
  3. 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

bash
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"]
  }'
FieldTypeRequiredDescription
urlstringYesHTTPS 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.
eventsstring[]YesList of event types to subscribe to. See Available events. At least one. Unknown event types return invalid_event_type.

Response 201 Created

json
{
  "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:

HeaderExamplePurpose
Content-Typeapplication/jsonBody is always JSON.
X-Humind-Event-Typeimport.completedWhich event fired. Same value as the event_type in the body.
X-Humind-Event-Idevt_8f3a1c2d4e5b6a7f1e2d3c4bUnique per event. Use it to dedupe, see Idempotency.
X-Humind-Timestamp1745595600Unix seconds when the delivery was generated. Used to verify the signature and reject replays.
X-Humind-Signaturet=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.

json
{
  "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:

  1. Read X-Humind-Timestamp and reject if it's outside a ±5 minute window vs your server clock (replay protection).
  2. Compute HMAC_SHA256(secret, "<timestamp>.<raw_body>") using the raw, unparsed request body.
  3. Compare against the v1= value in X-Humind-Signature using 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

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

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

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)
end

The Webhook Endpoint object

FieldTypeDescription
idstringStable identifier, prefixed whe_. Use it in the path of every subsequent call.
urlstringHTTPS URL Humind delivers to.
eventsstring[]Event types this endpoint is subscribed to.
status'active' | 'disabled'Whether deliveries are currently being attempted. See Disabling vs deleting.
prefixstringFirst 22 characters of the signing secret. Safe to log; useful to confirm which secret is in use after a rotation.
failure_countintegerNumber of consecutive failed deliveries since the last successful one. Resets to 0 on any successful delivery.
last_delivered_atISO 8601 | nullWhen the last successful delivery completed. null if no delivery has succeeded yet.
last_failed_atISO 8601 | nullWhen the most recent failed delivery happened. null if none have failed.
disabled_reasonstring | nullSet when the endpoint was auto-disabled. consecutive_failures.
created_atISO 8601When 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.

FieldTypeDescription
idstringStable identifier, prefixed whd_.
webhook_endpoint_idstringThe endpoint this delivery targeted.
event_typestringE.g. import.completed.
event_idstringUnique 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.
attemptsobject[]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_atISO 8601 | nullWhen the next retry will fire. null once the delivery is delivered or permanently_failed.
delivered_atISO 8601 | nullWhen a 2xx was first received. null until then.
permanently_failed_atISO 8601 | nullWhen the delivery was given up on. null if not exhausted.
created_atISO 8601When the event fired.

Available events

EventDescriptionPayload
import.completedA bulk import reached done. Counts and report breakdown match the final GET /imports/{sync_id}.Schema below
import.failedA bulk import reached failed (fatal failure during ingestion, not per-line validation).Schema below

import.completed

json
{
  "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

json
{
  "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:

AttemptDelay since previousCumulative
1 (initial)00
210 s10 s
31 min~1 min
45 min~6 min
530 min~36 min
62 h~2 h 36 min
712 h~14 h 36 min
824 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

bash
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

json
{
  "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:

  1. Add the new secret to your secret manager under a new version / pending slot.
  2. Update your handler to accept either the old or the new secret during a short window.
  3. Call POST /rotate-secret.
  4. 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:

ActionEndpointEffectRe-enable?
DisablePATCH /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" }.
DeleteDELETE /webhooks/endpoints/:idEndpoint 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

bash
curl "https://api.thehumind.com/public/v1/webhooks/deliveries?endpoint_id=whe_5f9e3a2b7c1d4e8a9b3f0c2d&status=permanently_failed&limit=20" \
  -H "Authorization: Bearer hmd_live_..."
Query paramTypeDescription
endpoint_idstringFilter to deliveries for a single endpoint.
status'pending' | 'delivered' | 'failed' | 'permanently_failed'Filter by lifecycle status.
limitintegerHow many deliveries to return. Default 20, max 100.

Response 200 OK

json
{
  "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:

StatusCodeWhenFix
403insufficient_scopeKey lacks webhooks:manage.Create a new key with webhooks:manage.
404webhook_endpoint_not_foundThe :id doesn't match an endpoint owned by your company.Confirm the id. Cross-tenant lookups also return 404.
404not_foundA delivery :id doesn't match anything for your company.Confirm the id.
422invalid_event_typeAn entry in events isn't a known type.Use one of the values listed under Available events.
422invalid_urlurl 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).
400validation_failedBody parsed but didn't match the schema (missing url, empty events, etc.).Fix the field and resend.
422webhook_disabledAn 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. Treat X-Humind-Event-Id as the idempotency key on your side: if you've already processed it, ack with 2xx and 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.parse it. A request that fails verification should be rejected with 401 or 400 and 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 /health or 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 full whsec_* string the same way you treat your API key.
  • Use a tunnel for local development. localhost is rejected in live mode by invalid_url. With a hmd_test_* key you can register https://your-id.ngrok.app and iterate locally without exposing a real URL.

Next

  • Imports: import.completed and import.failed are the events that fire today.
  • Authentication: generate a webhooks:manage key.
  • Errors: full error code reference.

Released under the proprietary Humind license.