Webhooks
Get an HTTPS POST when a launch finishes, an ad is reviewed, delivery changes or a day's spend closes. Verify every delivery with the Standard Webhooks libraries.
Koast sends an HTTPS POST to your endpoint when something happens in your organization. Webhooks are available on Agency plans. Manage endpoints in Koast under Settings, Webhooks, or with the /v1/webhooks endpoints below.
Event types
| Type | Sent when |
|---|---|
launch.completed | A launch published every campaign, ad set and ad |
launch.partially_failed | A launch published some entities and failed others |
launch.failed | A launch failed |
ad.approved | An ad passed the platform's review and is live or scheduled |
ad.rejected | The platform rejected an ad. data.reason carries the platform's message |
delivery.status_changed | A campaign, ad set or ad changed delivery status. Checked every 15 minutes |
metrics.updated | Fresh totals for an ad account. Hourly |
spend.day_closed | An ad account's day ended, in its own timezone, with that day's spend |
ping | Only from a test send |
Each type's full schema and an example payload are in Webhook events.
Each review or delivery status change is sent exactly once, even when the Koast dashboard refreshes the status in between checks.
The payload
Every event shares one envelope. data holds the full object, so you don't need to call back for details:
{
"id": "evt_3e744878-f9b8-4092-8d6d-5b12cb1fb03e",
"type": "ping",
"createdAt": "2026-10-09T04:49:09.043Z",
"apiVersion": "v1",
"organizationId": 1,
"brandId": null,
"accountId": null,
"provider": null,
"data": { "message": "This is a test event from Koast." }
}| Field | What |
|---|---|
id | Unique event id, evt_…. The same across retries. Dedupe on it. |
type | The event type |
createdAt | When the event happened |
apiVersion | Always v1 |
organizationId, brandId, accountId | Where it happened. brandId and accountId are null on organization-wide events like ping |
provider | meta, whop or null |
data | The event's object |
Each delivery also carries these headers:
| Header | What |
|---|---|
webhook-id | The event id |
webhook-timestamp | Unix seconds when Koast signed it |
webhook-signature | v1,<base64 HMAC-SHA256>. One per valid secret, space-separated, so two or more after a secret rotation |
content-type | application/json |
user-agent | Koast-Webhooks/1.0 |
Large launches
A launch with about 1,000 ads or more makes a launch.* event larger than an event can be. Koast then sends it trimmed rather than not at all:
data.truncatedistrue, anddata.campaigns,data.adSetsanddata.adsare empty.data.countsgives, for campaigns, ad sets and ads, thetotaland how manysucceeded,failedor wereskipped.data.entityIdslists every entity'skoastId,providerId,accountIdandstatus, without names or errors. It's left out when even the ids don't fit.data.fullObjectPathis the API path of the whole launch tree,/v1/brands/{brandId}/launches/{launchId}/tree.GETit for every entity with its platform id and status.
truncated, counts, entityIds and fullObjectPath appear only on a trimmed event. An event that wasn't trimmed has no truncated key. Check it before reading the entity arrays. This applies to launch.completed, launch.failed and launch.partially_failed.
Create an endpoint
curl -X POST https://api.koast.ai/v1/webhooks \
-H "Authorization: Bearer $KOAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.fernhill.example/koast",
"eventTypes": ["launch.completed", "launch.failed", "launch.partially_failed"],
"brandIds": [1]
}'201 Created. The response is the endpoint plus its signing secret:
{
"id": "ep_6f2d0c4b9e8a4d1f8b7c3a2e1d0f9a8b",
"url": "https://hooks.fernhill.example/koast",
"eventTypes": ["launch.completed", "launch.failed", "launch.partially_failed"],
"brandIds": [1],
"status": "enabled",
"disabledReason": null,
"disabledAt": null,
"consecutiveFailures": 0,
"lastSuccessAt": null,
"lastFailureAt": null,
"lastError": null,
"secretRotationExpiresAt": null,
"createdAt": "2026-10-09T05:00:41.102Z",
"updatedAt": "2026-10-09T05:00:41.102Z",
"secret": "whsec_..."
}Rules:
urlmust behttps, with no username or password in it, up to 2048 characters.eventTypesis one or more types from the table.pingcan't be subscribed to: test sends always arrive.brandIdslimits the endpoint to those brands. Leave it out, or sendnull, for every brand.- An organization can have up to 20 endpoints. The 21st is refused with
422 unprocessable. - It needs the
writescope. A brand-limited key can only create endpoints filtered to its own brands.
Get the secret again any time with GET /v1/webhooks/{endpointId}/secret. Reading it needs the write scope, because the secret lets anyone forge deliveries to your endpoint: a read-only key gets 403 insufficient_scope.
Verify every delivery
Use the official Standard Webhooks library for your language. It checks the signature and rejects deliveries more than 5 minutes old, which stops replays.
Verify the raw request body, byte for byte. Parsing the JSON and serialising it again changes the bytes and breaks the signature.
Node.js
npm install standardwebhooksimport express from 'express';
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.KOAST_WEBHOOK_SECRET);
const app = express();
app.post('/koast', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers);
} catch {
return res.status(400).end();
}
res.status(204).end();
handle(event);
});Python
pip install standardwebhooksimport os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["KOAST_WEBHOOK_SECRET"])
app = Flask(__name__)
@app.post("/koast")
def koast():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "", 400
handle(event)
return "", 204Go
go get github.com/standard-webhooks/standard-webhooks/librariesimport (
"io"
"net/http"
"os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func koast(w http.ResponseWriter, r *http.Request) {
wh, err := standardwebhooks.NewWebhook(os.Getenv("KOAST_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil || wh.Verify(body, r.Header) != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
handle(body)
}PHP
composer require standard-webhooks/standard-webhooks<?php
require 'vendor/autoload.php';
$wh = new \StandardWebhooks\Webhook(getenv('KOAST_WEBHOOK_SECRET'));
$payload = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$event = $wh->verify($payload, $headers);
} catch (\StandardWebhooks\Exception\WebhookVerificationException $e) {
http_response_code(400);
exit;
}
http_response_code(204);
handle($event);The libraries accept the secret with its whsec_ prefix as Koast shows it.
Respond fast
- Answer any
2xxwithin 10 seconds. Anything else counts as a failure: another status, a redirect, a network error or a timeout. - Koast never follows redirects. Point the endpoint at the final URL.
- Do the work after you answer, or put the event on your own queue.
Delivery guarantees
- At least once. The same event can arrive more than once. Store the
ids you've handled and skip repeats. - No ordering. Events can arrive in any order. Use
createdAtand the state indata, not arrival order.
Retries and disabling
A failed delivery is retried with growing gaps: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours, then every 12 hours.
After about 3 days of failures, Koast stops retrying that event. If your endpoint answered no delivery with a 2xx in that time, its status becomes disabled_by_failures, and the organization owner and admins get one email with the URL, the last error and how to turn it back on. If other deliveries succeeded meanwhile, the endpoint stays enabled and only that event is given up: it shows in the delivery log as a final failed attempt, and resend-failed sends it again.
To turn it back on, fix your endpoint, then:
curl -X POST https://api.koast.ai/v1/webhooks/ep_6f2d0c4b9e8a4d1f8b7c3a2e1d0f9a8b/enable \
-H "Authorization: Bearer $KOAST_API_KEY"Then replay what you missed:
curl -X POST https://api.koast.ai/v1/webhooks/ep_6f2d0c4b9e8a4d1f8b7c3a2e1d0f9a8b/resend-failed \
-H "Authorization: Bearer $KOAST_API_KEY"202 Accepted with { "queued": <number of events> }. It resends up to 100 events whose deliveries gave up, from the 500 most recent attempts. Only events the endpoint still subscribes to are resent: an event whose type or brand you've since removed from the endpoint is skipped.
Changing an endpoint's eventTypes or brandIds also applies to retries already queued. A retry the endpoint no longer subscribes to isn't sent, and shows in the delivery log as a final failed attempt with the error endpoint_unsubscribed.
Test sends
Send a ping to check your endpoint and your verification code:
curl -X POST https://api.koast.ai/v1/webhooks/ep_6f2d0c4b9e8a4d1f8b7c3a2e1d0f9a8b/test \
-H "Authorization: Bearer $KOAST_API_KEY"202 Accepted with { "eventId": "evt_…", "queued": true }. The ping payload is the example at the top of this page.
A disabled endpoint answers 409 conflict with "Enable the endpoint first." and nothing is sent. Turn it back on with POST /v1/webhooks/{endpointId}/enable first.
Delivery log
Every attempt is logged for 30 days, newest first, cursor-paged like every list:
curl "https://api.koast.ai/v1/webhooks/ep_6f2d0c4b9e8a4d1f8b7c3a2e1d0f9a8b/deliveries?limit=20" \
-H "Authorization: Bearer $KOAST_API_KEY"Each row has the event eventId and eventType, the attempt number, status (succeeded or failed), whether it was final, your httpStatus, latencyMs, the first 2 KB of your responseBody, the error, attemptedAt, nextAttemptAt, and whether it was a resend.
Rotate the secret
curl -X POST https://api.koast.ai/v1/webhooks/ep_6f2d0c4b9e8a4d1f8b7c3a2e1d0f9a8b/rotate-secret \
-H "Authorization: Bearer $KOAST_API_KEY"{ "secret": "whsec_...", "previousSecretExpiresAt": "2026-10-10T05:10:00.000Z" }For the next 24 hours Koast signs every delivery with both secrets, so the webhook-signature header carries two signatures. Either secret verifies. Deploy the new secret within that window. After it, only the new one works.
If you rotate again within those 24 hours, each replaced secret still keeps its own 24 hours, and every delivery carries one signature per secret that is still valid. Koast keeps at most 5 replaced secrets, so a sixth rotation within 24 hours retires the oldest early.
Two rotations sent at the same moment don't both win. The one that lands second answers 409 conflict and changes nothing, so no secret you were handed is lost. Read the current secret with GET /v1/webhooks/{endpointId}/secret, and rotate again if you still need to.
Endpoint safety
- Your endpoint's hostname must resolve to public addresses. Private, loopback, link-local and cloud metadata addresses are refused, and that refusal is final for the delivery.
- An
httpURL, or one with credentials in it, is refused when you create the endpoint.
All webhook operations
| Method | Path | Does |
|---|---|---|
GET | /v1/webhooks | List endpoints |
POST | /v1/webhooks | Create an endpoint, 201 |
GET | /v1/webhooks/{endpointId} | Read one |
PATCH | /v1/webhooks/{endpointId} | Change url, eventTypes, brandIds or status (enabled, disabled) |
DELETE | /v1/webhooks/{endpointId} | Delete, 204 |
POST | /v1/webhooks/{endpointId}/test | Send a ping, 202 |
POST | /v1/webhooks/{endpointId}/rotate-secret | New secret, old one valid 24 hours |
GET | /v1/webhooks/{endpointId}/secret | Current secret |
GET | /v1/webhooks/{endpointId}/deliveries | Delivery log |
POST | /v1/webhooks/{endpointId}/resend-failed | Replay failed events, 202 |
POST | /v1/webhooks/{endpointId}/enable | Turn a disabled endpoint back on |
Reads need read, except reading the secret, which needs write. Everything else needs write. Full schemas are in the API reference.
Versioning
v1 only ever grows. New endpoints, optional parameters and response fields arrive without notice. A breaking change ships as /v2, next to v1.
Trackers
Koast can push each campaign's live spend into RedTrack or ClickFlare every hour. Turn the push on, check its status and fix campaign mappings through the API.