Windpaint
Account

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:

codeCause
webhook.unknown_eventsOne or more event names aren’t in the list. details.unknown_events names them and details.allowed_events lists every valid name.
webhook.invalid_urlThe URL isn’t an absolute http(s) URL with a host.
webhook.insecure_urlThe URL uses http://. Use https://.

Events

EventFires whendata
member.invitedSomeone is invited to the organization.member_id, member_email, role
member.joinedAn invited person accepts.member_id, member_email, role
member.role_changedA member’s role changes.member_id, member_email, role (the new role)
member.removedA member is removed.member_id, member_email, role
api_key.createdAn 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.revokedAn 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.openedSomeone in your organization opens a support ticket.ticket_id, ticket_number, subject_line, type, priority, state
support.ticket.repliedWindpaint support replies to one of your tickets.ticket_id, ticket_number, subject_line, author, body, recipient_email
support.ticket.state_changedA 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_lowAvailable credits drop below your low-balance threshold. Once per crossing.available, threshold (decimal strings)
webhook.testYou 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_prefix is the key’s first 12 characters, as listed by GET /v1/api-keys; the key itself is never sent. user_id is the key’s creator. role_id and expires_at are null when the key has no bound role or no expiry.
  • support.ticket.*: ticket_number is the reference shown on the ticket in the dashboard. state and previous_state are one of open, triaging, awaiting_customer, resolved or closed. type is question, bug, access, billing or account; priority is P1 to P4.

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:

  1. Read the raw body bytes before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
  2. Compute sha256= + hex HMAC-SHA256 of those bytes with your secret.
  3. Compare with the header using a constant-time comparison.
  4. Reject the request with 401 if they don’t match.
Python (Flask)
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["WINDPAINT_WEBHOOK_SECRET"]


def verify(body: bytes, header: str | None) -> bool:
    expected = "sha256=" + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")


@app.post("/hooks/windpaint")
def windpaint_hook():
    body = request.get_data()  # raw bytes
    if not verify(body, request.headers.get("X-Windpaint-Signature")):
        abort(401)
    event = request.get_json()
    if event["event"] == "billing.credits_low":
        alert(f"Windpaint credits low: {event['data']['available']} left")
    return "", 204

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 2xx quickly and do slow work after responding.
  • Deliveries aren’t retried. A timeout, a connection error or a non-2xx response drops that delivery. Don’t rely on webhooks as the only record; for balance, poll GET /v1/billing/balance as 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.removed twice should be harmless.
  • Payloads have no event id. To de-duplicate, key on the event name and its data (for example member_id and role).
  • A webhook with "is_active": false receives nothing. Pause one with PATCH /v1/webhooks/{id}.

Managing webhooks

TaskRequest
ListGET /v1/webhooks
Change URL, events or descriptionPATCH /v1/webhooks/{id}
Pause or resumePATCH /v1/webhooks/{id} with {"is_active": false} or true
Send a test eventPOST /v1/webhooks/{id}/test
DeleteDELETE /v1/webhooks/{id}
Rotate the secretCreate 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 webhooksJob webhooks
Set up withPOST /v1/webhooks, or Settings → Webhookswebhook_url on each submit request
CoversMember, API key, support ticket and billing events for the organizationOne generation job reaching a final status
Body{"event": ..., "data": ...}The job’s status object
SignedYes, X-Windpaint-SignatureNo. Confirm by fetching the job’s status with your key.
RetriedNoNo

There are no webhooks for product runs; poll the run instead.

Next steps