Guides

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

TypeSent when
launch.completedA launch published every campaign, ad set and ad
launch.partially_failedA launch published some entities and failed others
launch.failedA launch failed
ad.approvedAn ad passed the platform's review and is live or scheduled
ad.rejectedThe platform rejected an ad. data.reason carries the platform's message
delivery.status_changedA campaign, ad set or ad changed delivery status. Checked every 15 minutes
metrics.updatedFresh totals for an ad account. Hourly
spend.day_closedAn ad account's day ended, in its own timezone, with that day's spend
pingOnly 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." }
}
FieldWhat
idUnique event id, evt_…. The same across retries. Dedupe on it.
typeThe event type
createdAtWhen the event happened
apiVersionAlways v1
organizationId, brandId, accountIdWhere it happened. brandId and accountId are null on organization-wide events like ping
providermeta, whop or null
dataThe event's object

Each delivery also carries these headers:

HeaderWhat
webhook-idThe event id
webhook-timestampUnix seconds when Koast signed it
webhook-signaturev1,<base64 HMAC-SHA256>. One per valid secret, space-separated, so two or more after a secret rotation
content-typeapplication/json
user-agentKoast-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.truncated is true, and data.campaigns, data.adSets and data.ads are empty.
  • data.counts gives, for campaigns, ad sets and ads, the total and how many succeeded, failed or were skipped.
  • data.entityIds lists every entity's koastId, providerId, accountId and status, without names or errors. It's left out when even the ids don't fit.
  • data.fullObjectPath is the API path of the whole launch tree, /v1/brands/{brandId}/launches/{launchId}/tree. GET it 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:

  • url must be https, with no username or password in it, up to 2048 characters.
  • eventTypes is one or more types from the table. ping can't be subscribed to: test sends always arrive.
  • brandIds limits the endpoint to those brands. Leave it out, or send null, for every brand.
  • An organization can have up to 20 endpoints. The 21st is refused with 422 unprocessable.
  • It needs the write scope. 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 standardwebhooks
import 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 standardwebhooks
import 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 "", 204

Go

go get github.com/standard-webhooks/standard-webhooks/libraries
import (
	"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 2xx within 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 createdAt and the state in data, 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 http URL, or one with credentials in it, is refused when you create the endpoint.

All webhook operations

MethodPathDoes
GET/v1/webhooksList endpoints
POST/v1/webhooksCreate 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}/testSend a ping, 202
POST/v1/webhooks/{endpointId}/rotate-secretNew secret, old one valid 24 hours
GET/v1/webhooks/{endpointId}/secretCurrent secret
GET/v1/webhooks/{endpointId}/deliveriesDelivery log
POST/v1/webhooks/{endpointId}/resend-failedReplay failed events, 202
POST/v1/webhooks/{endpointId}/enableTurn 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.

On this page