Webhooks
Send team events to your HTTPS endpoint, Slack, or Discord, and verify signed deliveries.
A team can register up to 10 destinations. Each event is recorded once, then delivered independently to every destination registered when the event is published. The supported kinds are webhook (signed JSON), slack, and discord. Today the event catalogue contains spending-threshold alerts.
Manage destinations in the console
Open your team's Billing page and find Webhook destinations. The owner, member, and billing roles can view the list. As an owner or billing user, choose a kind, enter a name and URL, and add the destination; you can also delete destinations there. For a generic webhook, the console shows the signing secret once, immediately after creation. Save it before dismissing it: the destination list never reveals it again. Slack and Discord URLs are masked in the list.
Register a destination
Team owner, member, and billing roles can list destinations. Only owner and billing can create or delete them; writes require a verified account. Authenticate API requests with an access token.
curl -X POST "https://api.nitroship.co/v1/teams/$TEAM_ID/webhooks" \
-H "Authorization: Bearer $NITROSHIP_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"kind":"webhook","name":"Spend alerts","url":"https://example.com/nitroship/events"}'A successful creation returns 201 with the destination:
{
"id": "<destination public ID>",
"kind": "webhook",
"name": "Spend alerts",
"url": "https://example.com/nitroship/events",
"created_at": "2026-10-04T12:00:00Z",
"secret": "whsec_<base64 key>"
}For webhook, save the secret immediately: it is returned only on creation, never by a later read. It is whsec_ followed by base64-encoded 32 random bytes. Slack and Discord destinations do not return a signing secret.
| Method and route | Result |
|---|---|
GET /v1/teams/{teamId}/webhooks | 200 {"items":[...]}, ordered by created_at; no secrets. |
POST /v1/teams/{teamId}/webhooks | Body {kind,name,url}; 201 destination, with secret only for webhook. |
DELETE /v1/teams/{teamId}/webhooks/{webhookId} | 204, no response body. |
IDs are Nitroship public IDs (canonical base62), not UUID strings. A destination has id, kind, name, url, and RFC3339 created_at. For webhook, url is the full URL. Slack and Discord URLs are credentials and are masked in responses as https://<host>/…<last 4 chars>.
There is no edit, secret-rotation, or test-send endpoint. To replace a destination, delete it and register a new one. Destinations have no per-event filters.
Names, URLs, and errors
kindmust bewebhook,slack, ordiscord.- A trimmed
namemust have 1–64 characters. urlmust have at most 2,048 characters, usehttps, have a host, and contain no user information or fragment. Generic endpoints must resolve only to globally routable IP addresses. Local/private endpoints cannot receive deliveries.slack: the host must be exactlyhooks.slack.com, and the path must start with/services/.discord: the host must bediscord.com,discordapp.com,ptb.discord.com, orcanary.discord.com, and the path must start with/api/webhooks/.
| Error code | HTTP status | Meaning |
|---|---|---|
invalid_id | 400 | Invalid public ID. |
invalid_webhook | 400 | Invalid kind, trimmed name, or URL. |
webhook_limit | 409 | The team already has 10 destinations. |
not_found | 404 | Resource unavailable, including a non-member or inactive team. |
forbidden | 403 | Insufficient team role. |
Connect Slack
- Create a Slack app for your workspace in Slack's app dashboard.
- Open Incoming Webhooks, enable them, then choose Add New Webhook to Workspace and authorize the target channel.
- Copy the incoming-webhook URL (
https://hooks.slack.com/services/...). Register it withkind: "slack"and a descriptivenameusing the same POST route above.
Keep the full URL private. Nitroship sends a readable text fallback and blocks: a title header, message section, fields, and a link/button when the event includes a console URL. Derived text escapes &, <, and >. This is a Slack-native message, not the signed JSON envelope below.
Connect Discord
- In the destination channel, open Edit Channel → Integrations → Webhooks (you need permission to manage webhooks).
- Create a webhook and choose Copy Webhook URL (
https://discord.com/api/webhooks/...). Register it withkind: "discord"and a descriptivename.
Keep the full URL private. Nitroship posts as Nitroship, disables mentions with allowed_mentions: {"parse":[]}, and sends an embed containing the title, description, optional console URL, event timestamp, and inline fields. Slack and Discord messages use the event's stored human-readable message, so every destination receives a rendering of the same event.
Signed event envelope
Generic webhook destinations receive Nitroship Webhooks v1, following Standard Webhooks:
{
"id": "<event public ID>",
"type": "billing.spend.threshold_reached",
"timestamp": "2026-10-04T12:00:00Z",
"team_id": "<team public ID>",
"data": {
"period_start": "2026-10-01T00:00:00Z",
"threshold": 50,
"budget_minor": 10000,
"spend_minor": 5000,
"currency": "USD",
"currency_minor_digits": 2
}
}id and team_id are public IDs. Body timestamp is the event's creation time in RFC3339, not the delivery attempt time. The recorded event data is immutable across retries.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Nitroship-Webhooks/1 |
webhook-id | The same event ID as body id. |
webhook-timestamp | Unix seconds of this delivery attempt. |
webhook-signature | v1,<base64 HMAC-SHA256> |
Verify signatures
- Read and retain the raw request body bytes, before JSON parsing or reserialization.
- Remove
whsec_from your secret and base64-decode the rest to obtain the HMAC key. - Compute HMAC-SHA256 over
webhook-id + "." + webhook-timestamp + "." + raw body. - Compare the received
v1signature in constant time. Reject invalid signatures before processing the event.
Also check the attempt timestamp to limit replay (the examples choose a five-minute receiver tolerance, not a server delivery expiry). Keep your receiver's clock synchronized. After verification, validate the expected team and event type, and deduplicate successful processing by event id: retries reuse the ID but get a new attempt timestamp and signature.
Node.js
Pass the unmodified body as a Buffer and the three header values as strings:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody, headers, secret, now = Date.now() / 1000) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !/^\d+$/.test(timestamp ?? '') || !signatures ||
!secret.startsWith('whsec_')) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 300) return false;
const key = Buffer.from(secret.slice(6), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`).update(rawBody).digest();
return signatures.split(/\s+/).some((entry) => {
if (!entry.startsWith('v1,')) return false;
const received = Buffer.from(entry.slice(3), 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}Only after verifyWebhook returns true should you parse JSON.parse(rawBody.toString('utf8')) and process the event.
Go
Call this with the raw body (read from r.Body), r.Header, your saved secret, and time.Now():
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"strconv"
"strings"
"time"
)
func verifyWebhook(body []byte, headers http.Header, secret string, now time.Time) bool {
id := headers.Get("webhook-id")
timestamp := headers.Get("webhook-timestamp")
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || id == "" || !strings.HasPrefix(secret, "whsec_") {
return false
}
age := now.Sub(time.Unix(seconds, 0))
if age < -5*time.Minute || age > 5*time.Minute {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, entry := range strings.Fields(headers.Get("webhook-signature")) {
if !strings.HasPrefix(entry, "v1,") {
continue
}
received, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(entry, "v1,"))
if err == nil && hmac.Equal(received, expected) {
return true
}
}
return false
}Only decode the JSON and process the event after verification succeeds. Return a 2xx response after accepting the event durably; make repeated deliveries safe.
Delivery and retries
Events and their pending deliveries are recorded together in a durable outbox inside the publisher's database transaction; publishing performs no network request. Re-publishing the same deduplication key within a team is a no-op. A destination registered later does not receive already-published events.
A separate leader-only dispatcher has 4 workers, polls at most every 10 seconds, and claims at most 64 due deliveries, ordered by next_attempt_at. One failing destination never blocks the others.
- Any
2xxresponse marks a delivery successful. Other responses and network errors are retried. - Retry delay:
min(1 minute × 2^(attempts − 1), 1 hour). A429response'sRetry-Afteris honored, capped at 1 hour. - Delivery is abandoned 72 hours after event creation, not 72 hours after the first attempt.
- Per-delivery deadline: 15 seconds; HTTP client timeout: 10 seconds; dial timeout: 5 seconds.
- Requests use only globally routable IPs, no proxy, and no redirects. Use the final HTTPS URL.
- Leader loss cancels in-flight requests; those deliveries remain pending. Receivers must handle duplicates if a request succeeded but its success was not recorded.
- Events older than 30 days are deleted once every delivery is terminal (delivered or failed).
Event catalogue
billing.spend.threshold_reached
Published when a billed team's traffic and Compute on-demand spending after monthly plan credits reaches 50%, 75%, or 100% of its budget. Checks run every 5 minutes. Budgets and spending remain fiat amounts in the team's billing currency, not credits. Platform fees and Flat Rate CDN tier fees do not count toward the budget. Operator-granted credits reduce invoice totals later and do not reduce this spending measure. Destination setup is separate from usage and billing settings: PUT /v1/teams/{teamId}/billing/spend takes only {budget_minor,pause_on_budget}, and GET/PUT return {budget_minor,pause_on_budget,thresholds}.
data field | Meaning |
|---|---|
period_start | UTC billing-period start in RFC3339. |
threshold | 50, 75, or 100 (percentage). |
budget_minor | Integer budget in the currency's smallest unit. |
spend_minor | Integer on-demand spending after monthly plan credits at publication, in the currency's smallest unit. |
currency | The team's current-cycle billing currency, an ISO 4217 code such as USD or KRW. |
currency_minor_digits | Integer minor-unit scale (USD: 2, KRW: 0); use it to display amounts. |
There are no other product event types today. Registering destinations does not itself set a budget or enable pause_on_budget; non-billed plans can save spend settings, but spend checks and alerts apply only to billed plans.
Migrating the former spend webhook
An existing non-empty spend webhook_url and its webhook_secret become a webhook destination named Spend alerts. The migrated secret is whsec_ plus base64 of the old secret's bytes: the HMAC key bytes are unchanged. To configure your receiver, encode the old secret's bytes in that format.
The wire format is a clean cutover: the old flat payload and X-Nitroship-Signature header are removed. Update receivers to the envelope and Standard Webhooks signing above. The spend API no longer accepts or returns webhook_url or webhook_secret; manage destinations through /v1/teams/{teamId}/webhooks.