Account webhooks
Receive signed notifications about your organization (members, API keys, support tickets, low balance) at your own HTTPS endpoint.
Account webhooks send organization events, such as a member joining, an API key being revoked or your balance running low, to an endpoint you control. Each delivery is a signed JSON POST.
curl -X POST https://api.windpaint.ai/v1/webhooks \
-H "Authorization: Bearer $WINDPAINT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/windpaint",
"events": ["billing.credits_low", "member.joined", "member.removed"],
"description": "ops alerts"
}'
Account webhooks don’t carry generation events. To hear when a job finishes, pass webhook_url on the submit request instead. See Job webhooks and the comparison below.
Creating a webhook
You need the owner or admin role.
In the dashboard, go to Settings → Webhooks, choose Add webhook, enter your endpoint URL and choose the events. Copy the signing secret when it’s shown.
Store the secret somewhere your receiver can read it. It’s never returned again; if you lose it, delete the webhook and create a new one.
The URL must be an absolute https:// URL. Event names are checked against the list below when you create or edit a webhook. Both are rejected with 422:
code | Cause |
|---|---|
webhook.unknown_events | One or more event names aren’t in the list. details.unknown_events names them and details.allowed_events lists every valid name. |
webhook.invalid_url | The URL isn’t an absolute http(s) URL with a host. |
webhook.insecure_url | The URL uses http://. Use https://. |
Events
| Event | Fires when | data |
|---|---|---|
member.invited | Someone is invited to the organization. | member_id, member_email, role |
member.joined | An invited person accepts. | member_id, member_email, role |
member.role_changed | A member’s role changes. | member_id, member_email, role (the new role) |
member.removed | A member is removed. | member_id, member_email, role |
api_key.created | An API key is created, in the dashboard or with POST /v1/api-keys. | key_id, name, key_prefix, user_id, role_id, expires_at |
api_key.revoked | An API key is revoked, in the dashboard or with DELETE /v1/api-keys/{id}. Pausing a key with PATCH and "is_active": false doesn’t send it. | key_id, name, key_prefix, user_id, role_id, expires_at |
support.ticket.opened | Someone in your organization opens a support ticket. | ticket_id, ticket_number, subject_line, type, priority, state |
support.ticket.replied | Windpaint support replies to one of your tickets. | ticket_id, ticket_number, subject_line, author, body, recipient_email |
support.ticket.state_changed | A ticket moves to another state, whether you or support moved it. Replying to a ticket that’s waiting on you reopens it and sends this too. | ticket_id, ticket_number, subject_line, state, previous_state |
billing.credits_low | Available credits drop below your low-balance threshold. Once per crossing. | available, threshold (decimal strings) |
webhook.test | You send a test with POST /v1/webhooks/{id}/test. Delivered to that one webhook only. | webhook_id, message, sent_at |
Every event’s data also includes organization_id. GET /v1/webhooks/events returns the same list of names.
Field notes:
api_key.*:key_prefixis the key’s first 12 characters, as listed byGET /v1/api-keys; the key itself is never sent.user_idis the key’s creator.role_idandexpires_atarenullwhen the key has no bound role or no expiry.support.ticket.*:ticket_numberis the reference shown on the ticket in the dashboard.stateandprevious_stateare one ofopen,triaging,awaiting_customer,resolvedorclosed.typeisquestion,bug,access,billingoraccount;priorityisP1toP4.
Testing a webhook
POST /v1/webhooks/{id}/test sends a signed webhook.test event to the endpoint right away and returns what happened. It’s sent even when the webhook is paused or isn’t subscribed to webhook.test, so you can check your receiver and its signature check before turning anything on. You need the webhooks:update permission. The call waits up to 10 seconds for your endpoint; a slower endpoint shows up as a failed delivery with an error.
curl -X POST https://api.windpaint.ai/v1/webhooks/2f4e6a8c-0b1d-4e3f-a5b7-c9d1e3f5a7b9/test \
-H "Authorization: Bearer $WINDPAINT_API_KEY"
{
"event": "webhook.test",
"delivery": {
"url": "https://example.com/hooks/windpaint",
"is_delivered": true,
"status_code": 204,
"error": null,
"duration_ms": 184
}
}
The call returns 201 whenever the attempt was made. delivery.is_delivered is true only for a 2xx answer; otherwise status_code is your endpoint’s status (or null when no response arrived) and error says what went wrong, for example Endpoint answered HTTP 401. Your receiver gets:
{"event":"webhook.test","data":{"organization_id":"c3a1e8f2-6b4d-4e0a-8f1c-2d7b9e5a1c33","webhook_id":"2f4e6a8c-0b1d-4e3f-a5b7-c9d1e3f5a7b9","message":"This is a test event sent from Windpaint.","sent_at":"2026-10-04T16:10:05.118204+00:00"}}
Payload
Every delivery is a POST with Content-Type: application/json and a compact JSON body:
{"event":"billing.credits_low","data":{"available":"84.20","threshold":"100.00","organization_id":"c3a1e8f2-6b4d-4e0a-8f1c-2d7b9e5a1c33"}}
{"event":"member.joined","data":{"member_id":"6a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d","member_email":"[email protected]","role":"member","organization_id":"c3a1e8f2-6b4d-4e0a-8f1c-2d7b9e5a1c33"}}
{"event":"api_key.revoked","data":{"key_id":"9a7c2e41-5b3d-4f6a-8c0e-1d2f3a4b5c6d","name":"render-worker","key_prefix":"aak_3f9c1a7e","user_id":"6a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d","role_id":null,"expires_at":null,"organization_id":"c3a1e8f2-6b4d-4e0a-8f1c-2d7b9e5a1c33"}}
The body has no event id and, apart from webhook.test, no timestamp.
Verifying signatures
Each delivery carries:
X-Windpaint-Signature: sha256=<hex>
<hex> is the HMAC-SHA256 of the raw request body, keyed with your webhook secret. The key is the secret string exactly as returned (its UTF-8 bytes), not hex-decoded.
To verify:
- Read the raw body bytes before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
- Compute
sha256=+ hex HMAC-SHA256 of those bytes with your secret. - Compare with the header using a constant-time comparison.
- Reject the request with
401if they don’t match.
The webhook receiver recipe has a complete deployable version.
Delivery behavior
- Deliveries go out within about 15 seconds of the event.
- Each delivery waits up to 10 seconds for your response. Return a
2xxquickly and do slow work after responding. - Deliveries aren’t retried. A timeout, a connection error or a non-
2xxresponse drops that delivery. Don’t rely on webhooks as the only record; for balance, pollGET /v1/billing/balanceas a backstop. - There’s no timestamp in the signature, so a captured request stays valid if replayed. Keep your endpoint on HTTPS and make handlers idempotent: processing the same
member.removedtwice should be harmless. - Payloads have no event id. To de-duplicate, key on the event name and its
data(for examplemember_idandrole). - A webhook with
"is_active": falsereceives nothing. Pause one withPATCH /v1/webhooks/{id}.
Managing webhooks
| Task | Request |
|---|---|
| List | GET /v1/webhooks |
| Change URL, events or description | PATCH /v1/webhooks/{id} |
| Pause or resume | PATCH /v1/webhooks/{id} with {"is_active": false} or true |
| Send a test event | POST /v1/webhooks/{id}/test |
| Delete | DELETE /v1/webhooks/{id} |
| Rotate the secret | Create a new webhook, switch your receiver to its secret, delete the old one |
Field details are in the Webhooks API reference.
Account webhooks vs job webhooks
| Account webhooks | Job webhooks | |
|---|---|---|
| Set up with | POST /v1/webhooks, or Settings → Webhooks | webhook_url on each submit request |
| Covers | Member, API key, support ticket and billing events for the organization | One generation job reaching a final status |
| Body | {"event": ..., "data": ...} | The job’s status object |
| Signed | Yes, X-Windpaint-Signature | No. Confirm by fetching the job’s status with your key. |
| Retried | No | No |
There are no webhooks for product runs; poll the run instead.