Skip to main content

Webhooks

Starboard can call an HTTPS endpoint of yours whenever one of your quotes changes, with an event type of its own for a quote that's just been won. Each request carries the quote exactly as Read Quote returns it, so the code that reads GET /api/v3/quotes/{quote_id} reads webhooks too.

Webhooks or polling​

  • Webhooks tell you about a change usually within a minute or two, without your system asking. You need an HTTPS endpoint that Starboard can reach from the internet.
  • Polling List Quotes with updated_since (see Incremental sync) needs only outbound calls from your system, on your own schedule, at most once a minute.
  • Both together is the most robust: act on webhooks as they arrive, and poll once a day to catch anything a webhook couldn't deliver (see Reconciling with polling).

How it works​

  1. About once a minute, Starboard checks for quotes that changed since its last check. It picks up at most 200 changed quotes per endpoint per check, so after a bulk change the last events can take a few minutes longer.
  2. For each changed quote your endpoint follows, it creates one event, based on the quote's latest version at that check.
  3. When it sends the event, it reads the quote again, so the body holds the quote's latest state and freshly signed links.
  4. It signs the request, posts it to your endpoint, and retries if the delivery fails (see Delivery).

Setting up an endpoint​

Your company has one webhook, which sends to one endpoint of yours. There's no API for managing it. If Starboard has turned on self-serve for your company, your company's admins set it up in Starboard under Settings → Developer settings, in the Webhooks section. Otherwise, Starboard sets it up for you: send your Starboard team these settings.

SettingDescription
URLWhere Starboard sends events: an https:// URL with no username or password in it, on a host whose addresses are all public, with a valid TLS certificate. Starboard doesn't follow redirects, so give the final URL.
EventsBoth quote.won and quote.updated by default, or only one of them. See Event types.
Quote statusesEvery status except temp by default, so you're sent every change to every quote; or only some statuses. See Which quotes you follow.
AuthenticationOptional, on top of the signature: Basic, a static bearer token, or OAuth 2.0 client credentials. See Authenticating Starboard to your endpoint.

The endpoint then gets its signing secret, which starts with whsec_. Starboard shows it only once, so store it securely when you receive it, like a password; if it's lost, rotate it. Once your endpoint is ready, send it a test event.

Starboard doesn't send webhooks from fixed IP addresses, so there's no address list to allowlist. Check the signature on every request instead (see Verifying signatures).

Only your company's admins see Settings → Developer settings. There they see the webhook's address, what it sends, its delivery health, its last 20 deliveries, and anything Starboard has turned off or paused. With self-serve, they can also edit the webhook, rotate its signing secret, send a test event (one at a time), resend a delivery, have Starboard check for changes right away (at most once a minute), turn the webhook off and on, and delete it. A webhook that Starboard turned off stays off until Starboard turns it back on. Without self-serve, ask your Starboard team for any of these.

Set up the endpoint before you load existing quotes

An endpoint is sent the changes made after it's set up, not earlier ones: a quote that doesn't change again isn't sent. So set up the endpoint first, then load your existing quotes with List Quotes and updated_since (see Incremental sync). The two overlap a little, which deduplicating takes care of.

Event types​

typeSent when
quote.wonOnce per win. A quote is won when it reaches win, booking_requested or booking_confirmed from any other status; moving between those three isn't a new win, and is sent as quote.updated. A quote that leaves them and reaches them again is won again. A quote won before your endpoint was set up is sent quote.won the first time it changes afterwards. If a quote.won couldn't be delivered, the quote's next change sends quote.won again.
quote.updatedEvery other change to a quote you follow: a new quote, an edit, or a status change, including leaving the won statuses (reopened, lost, or a booking rejected). An endpoint that doesn't subscribe to quote.won receives a newly won quote as quote.updated.
webhook.testA test, sent when your admins or Starboard ask for one (see Testing and troubleshooting). Its data is {"endpoint_id": "..."}, not a quote. Respond 2xx.

Handle quote.won and quote.updated the same way: upsert the quote by data.quote.id and act on data.quote.status, not on the event type alone. Respond 2xx to a type you don't recognize, and ignore it.

Which quotes you follow​

By default an endpoint follows every quote, whatever its status, so you're sent every change. temp quotes are never sent.

If your endpoint is set to send only some statuses, it follows:

  • every quote in one of those statuses. Following win covers booking_requested and booking_confirmed too; and
  • every quote it has already had an event for, whatever its status now. That's how you hear about a won quote that's reopened, lost, or has its booking rejected, if your endpoint receives quote.updated.
Turned off by Starboard

Starboard may turn off some quote statuses or event types for all customers (for example, not sending drafts). Nothing is sent for anything turned off, whatever your endpoint's own settings, and that includes events already waiting to be sent when it was turned off. Changes held back while something was turned off aren't sent when it's turned back on, apart from those of the last couple of minutes: each such quote is sent at its next change. Your admins can see what's turned off under Settings → Developer settings.

What one check sees

Events report what Starboard sees when it checks, about once a minute: at most one event per quote per check, carrying its latest version, so several quick changes to a quote reach you as one event. A quote that's won and then moves out of the won statuses (for example, to loss) before the next check is never reported as won. By default you're sent a quote.updated with its new status. An endpoint limited to some statuses gets nothing for it, unless the new status is one it follows or it has had an event for the quote before.

The event body​

An example quote.won event:

{
"id": "evt_5ead7aaee6da41be8f2a3b05b17197be",
"type": "quote.won",
"timestamp": "2026-11-03T15:04:02.517Z",
"data": {
"quote": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"company_id": "00000000-0000-0000-0000-000000000001",
"rfq_id": "00000000-0000-0000-0000-000000000002",
"quote_number": "Q-000001",
"status": "win",
"customer_company_id": "00000000-0000-0000-0000-000000000003",
"incoterm": "FOB",
"commodity": "Electronics",
"effective_date": "2026-11-01",
"expiring_date": "2026-11-30",
"display_currency": "USD",
"created_at": "2026-10-28T13:20:05.118Z",
"updated_at": "2026-11-03T15:03:41.218Z",
"lanes": [ ... ],
"routes": [ ... ]
},
"download_url": "https://example.com/quote.pdf?token={signed-token}"
}
}
FieldTypeDescription
idstringThe event's ID: evt_ followed by 32 hexadecimal characters. The same as the webhook-id header, on every attempt and when Starboard resends the event.
typestringquote.won, quote.updated or webhook.test. See Event types.
timestampstringWhen Starboard created the event: an RFC 3339 timestamp in UTC, with milliseconds. The same on every attempt.
dataobjectThe body Read Quote returns for the quote, unchanged: quote (a Quote) and download_url.
  • One parser for both. data is Read Quote's response, byte for byte, so whatever reads GET /api/v3/quotes/{quote_id} reads data, and data.quote is the same Quote object that List Quotes returns. You don't need to call Read Quote when an event arrives.
  • The quote as of sending. Starboard reads the quote when it sends the event, so data holds the quote's state at that moment. If the quote has changed again by then, a quote.updated is dropped in favour of the newer event; a quote.won is still sent, so its data.quote.status may already have moved on.
  • Signed links expire after an hour. download_url, like each rate's vendor_email_url, is signed when the event is sent and is valid for one hour. Download what you need while you process the event; later, call Read Quote or Download Quote Document for a new link. download_url is "" when the quote has no PDF, or when the link couldn't be made.
  • A retry's body can differ. Each attempt reads and signs the quote again: its links are new, and values worked out when the quote is read can differ. Deduplicate on webhook-id, never on the body.

Request headers​

Starboard sends each event as a POST to your URL, with these headers. They follow the Standard Webhooks specification, so off-the-shelf libraries can verify them.

HeaderDescription
webhook-idThe event's id, such as evt_5ead7aaee6da41be8f2a3b05b17197be. The same on every attempt and when Starboard resends the event: use it as your idempotency key.
webhook-timestampWhen this attempt was sent, in Unix seconds, such as 1793718243. New on every attempt.
webhook-signatureThe signature: v1, followed by a base64 string, such as v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=. During a secret rotation, two signatures separated by a space.
Content-Typeapplication/json
User-AgentStarboard-Webhooks/1
AuthorizationOnly when you've chosen endpoint authentication: Basic ... or Bearer ...

Header names are case-insensitive: your framework may show them as, for example, Webhook-Id.

Verifying signatures​

Verify every request before you act on it. Anyone who learns your URL can send it requests; only Starboard and you know the signing secret.

The signature is made the Standard Webhooks way:

  1. Key: the signing secret without its whsec_ prefix, base64-decoded. Use the decoded bytes, not the string.
  2. Signed content: the webhook-id header, a full stop, the webhook-timestamp header, a full stop, and the raw request body: {webhook-id}.{webhook-timestamp}.{body}.
  3. Signature: the HMAC-SHA256 of the signed content with the key, base64-encoded.

To verify, compute the signature yourself and compare it, in constant time, with each entry in webhook-signature: split the header on spaces, keep the entries that start with v1,, and drop that prefix. Accept the request if any entry matches. Also reject it if webhook-timestamp is more than 5 minutes away from your clock, so that a recorded request can't be replayed later.

Verify the raw body

Compute the signature over the body exactly as received, before parsing it. JSON that's been parsed and serialized again almost never has the same bytes (spacing, key order, number formatting), so its signature won't match.

When verification fails, respond 400. Starboard retries any response other than 2xx or 410, so if deliveries fail because your secret was wrong, they still arrive once you've fixed it, within the retry schedule. Never respond 410 to a failed check: 410 disables the endpoint.

The libraries below take the secret exactly as Starboard gives it, whsec_ prefix included, and check the timestamp for you.

Node.js​

Install the standardwebhooks package:

npm install standardwebhooks
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.STARBOARD_WEBHOOK_SECRET); // the whsec_... secret, as given

// express.raw keeps the body as the exact bytes Starboard sent. Don't let a
// JSON body parser run on this route first.
app.post("/starboard/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // checks the signature and the timestamp
} catch (err) {
return res.status(400).send("Invalid signature");
}

// Store the event, keyed on its webhook-id, and process it asynchronously.
saveForProcessing(req.headers["webhook-id"], event);
res.sendStatus(200);
});

Python​

Install the standardwebhooks package:

pip install standardwebhooks
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
wh = Webhook(os.environ["STARBOARD_WEBHOOK_SECRET"]) # the whsec_... secret, as given


@app.post("/starboard/webhooks")
def starboard_webhook():
try:
# get_data() is the raw body, before any JSON parsing.
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "Invalid signature", 400

# Store the event, keyed on its webhook-id, and process it asynchronously.
save_for_processing(request.headers["webhook-id"], event)
return "", 200

Salesforce (Apex)​

Apex has no Standard Webhooks library, so verify by hand. StarboardWebhookVerifier checks a request, and StarboardWebhookResource is an Apex REST endpoint that uses it. For how Starboard signs in to Salesforce, see Salesforce below.

StarboardWebhookVerifier.cls
public with sharing class StarboardWebhookVerifier {
private static final Long TOLERANCE_SECONDS = 5 * 60;

// True when the request carries a valid, recent Starboard signature.
public static Boolean isValid(RestRequest req, String secret) {
String msgId = header(req, 'webhook-id');
String msgTimestamp = header(req, 'webhook-timestamp');
String msgSignatures = header(req, 'webhook-signature');
if (String.isBlank(msgId) || String.isBlank(msgTimestamp) || String.isBlank(msgSignatures)) {
return false;
}

// Replay protection: reject timestamps more than 5 minutes off.
Long sentAt;
try {
sentAt = Long.valueOf(msgTimestamp);
} catch (TypeException e) {
return false;
}
Long now = Datetime.now().getTime() / 1000;
if (Math.abs(now - sentAt) > TOLERANCE_SECONDS) {
return false;
}

// Sign the raw body, never a re-serialized copy.
String expected = sign(secret, msgId, msgTimestamp, req.requestBody.toString());

// During a secret rotation the header holds two signatures: accept either.
for (String versioned : msgSignatures.split(' ')) {
if (versioned.startsWith('v1,') && constantTimeEquals(versioned.substring(3), expected)) {
return true;
}
}
return false;
}

// base64(HMAC-SHA256 of "id.timestamp.body"), keyed with the
// base64-decoded part of the secret after "whsec_".
public static String sign(String secret, String msgId, String msgTimestamp, String body) {
Blob key = EncodingUtil.base64Decode(secret.removeStart('whsec_'));
Blob content = Blob.valueOf(msgId + '.' + msgTimestamp + '.' + body);
return EncodingUtil.base64Encode(Crypto.generateMac('hmacSHA256', content, key));
}

// Header names are case-insensitive.
private static String header(RestRequest req, String name) {
for (String key : req.headers.keySet()) {
if (key.equalsIgnoreCase(name)) {
return req.headers.get(key);
}
}
return null;
}

private static Boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) {
return false;
}
Integer diff = 0;
for (Integer i = 0; i < a.length(); i++) {
diff |= a.charAt(i) ^ b.charAt(i);
}
return diff == 0;
}
}
StarboardWebhookResource.cls
@RestResource(urlMapping='/starboard/webhooks')
global with sharing class StarboardWebhookResource {
@HttpPost
global static void receive() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;

if (!StarboardWebhookVerifier.isValid(req, signingSecret())) {
res.statusCode = 400;
return;
}

// Respond within 15 seconds: save the raw event, skipping a webhook-id
// you've already saved, and process it asynchronously (for example, in
// a Queueable job).
res.statusCode = 200;
}

private static String signingSecret() {
// For example, from a protected custom setting that only admins can read.
return Starboard_Settings__c.getOrgDefaults().Webhook_Signing_Secret__c;
}
}

To check a verifier you wrote yourself, run its signing step on this example from the Standard Webhooks specification. The example is from 2021, so leave the timestamp check out of this test.

InputValue
Signing secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
Body{"test": 2432232314}
Expected webhook-signaturev1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

With the Apex class above:

System.assertEquals(
'g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=',
StarboardWebhookVerifier.sign(
'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw',
'msg_p5jXN8AQM9LWM0D4loKWxJek',
'1614265330',
'{"test": 2432232314}'
)
);

Authenticating Starboard to your endpoint​

The signature proves that a request came from Starboard. If your endpoint also requires credentials of its own (for example, it sits behind an API gateway, or it's a Salesforce Apex REST endpoint), Starboard can send them:

OptionWhat Starboard sends
None (the default)The signature headers only
BasicAuthorization: Basic ..., made from a username and password you give Starboard
Bearer tokenAuthorization: Bearer ..., with a static token you give Starboard
OAuth 2.0 client credentialsAuthorization: Bearer ..., with an access token Starboard gets from your authorization server

Keep verifying the signature whichever you choose.

OAuth 2.0 client credentials​

Give Starboard your token URL, a client ID and a client secret and, if your server needs them, a scope and an audience. The token URL follows the same rules as the endpoint URL: https://, a public host, no redirects. Starboard then:

  1. Requests a token with a form-encoded POST to your token URL: grant_type=client_credentials, plus your scope and audience if you gave them. The client ID and secret go in the body as client_id and client_secret (the default), or as HTTP Basic authentication if your server expects that. Your token endpoint must answer within 10 seconds, with HTTP 200 and an access_token.
  2. Reuses the token until a minute before it expires (expires_in), or for 10 minutes when the token response has no expires_in.
  3. Sends it with each delivery as Authorization: Bearer ....
  4. If your endpoint responds 401, gets a new token and resends once, straight away.

If Starboard can't get a token, or the resend fails too, the delivery counts as failed and is retried on the usual schedule.

Salesforce​

Apex REST endpoints normally require a signed-in caller, so to receive webhooks in Salesforce, expose an Apex REST class (such as StarboardWebhookResource above) and let Starboard sign in with the OAuth 2.0 client credentials flow:

  1. In Salesforce, create a connected app with OAuth enabled, turn on its client credentials flow, and set its run-as user to a user with API access and access to the Apex class.
  2. Send Starboard:
    • Endpoint URL: https://<your-domain>.my.salesforce.com/services/apexrest/starboard/webhooks. The path after apexrest is the class's urlMapping.
    • Token URL: https://<your-domain>.my.salesforce.com/services/oauth2/token. Use your My Domain host: Salesforce doesn't accept login.salesforce.com for this flow.
    • Client ID and client secret: the connected app's consumer key and consumer secret, sent in the request body.
  3. Salesforce's token response has no expires_in, so Starboard gets a new token every 10 minutes, and straight away whenever Salesforce responds 401.

Delivery​

Responding​

  • One event at a time. Starboard sends your endpoint one event at a time: the next delivery starts only when the current attempt has finished. A slow endpoint therefore delays only its own events, and how fast you respond sets how fast a backlog clears.
  • Respond 2xx within 15 seconds. Starboard waits up to 15 seconds for your response, 5 of them to connect, and treats anything slower as a failure, which it retries. Save the event and process it afterwards, for example from a queue.
  • Any 2xx means received. Starboard reads only the first 1 KB of your response, and keeps it with the delivery attempt for troubleshooting.

Retries​

Starboard retries a delivery after any status other than 2xx or 410 (redirects included), a timeout or a connection error:

RetryDelay after the previous attempt
11 minute
25 minutes
330 minutes
42 hours
55 hours
6 to 910 hours each

Each delay varies by up to 10%. The last retry comes about two days after the first attempt (47.6 hours, before that variation); if it fails too, Starboard stops trying. You can still catch the change by reconciling.

  • Retry-After. On a 429 or 503, Starboard reads the Retry-After header (in seconds or as an HTTP date) and never retries sooner than it asks, counting at most one hour. When the scheduled delay is longer, it waits the scheduled delay.
  • 410 Gone disables the endpoint at once: Starboard sends it nothing more until it's re-enabled. Respond 410 only when you want deliveries to stop.
  • Redirects aren't followed: a 3xx counts as a failure.
  • Failing endpoints are disabled. An endpoint whose deliveries keep failing, with no success, for more than 3 days in a row is disabled. A stretch of more than 12 hours with nothing sent, for example a long pause, starts the count again.
  • Re-enabling. With self-serve, your admins turn a disabled endpoint back on under Settings → Developer settings, unless Starboard turned it off; otherwise, contact your Starboard team. Starboard then picks up where the endpoint stopped, and sends events for the quotes that changed while it was disabled, working through up to 200 changed quotes a minute. Events that came due while it was disabled, and events that ran out of retries, aren't sent again unless their quote changes again or they're resent, so sync the changes from the time your endpoint started failing with List Quotes and updated_since.

Paused deliveries​

Starboard can pause all webhook deliveries for a while, for example during maintenance. While deliveries are paused, nothing is sent, and test events can't be sent. When deliveries resume, events that were already waiting are sent, and each quote that changed during the pause is sent once, in its latest state. So a quote that was won and then lost during a pause arrives as quote.updated, not quote.won. Unlike a disabled endpoint, a pause doesn't drop any changes. Settings → Developer settings shows when deliveries are paused.

Duplicates and order​

  • At least once. The same event can arrive more than once, for example when your 2xx doesn't reach Starboard in time. Keep the webhook-ids you've processed; when one arrives again, respond 2xx and skip it.
  • No ordering. Events can arrive out of order, for example when a retried event arrives after a newer one. For each quote, keep the data.quote.updated_at you last applied, and skip an event that carries an older one. (A poll always returns the current state, which is why polling compares for any difference instead. In the rare case where a quote's updated_at moves backwards, your next reconcile picks the quote up.)
  • Not every version. Several quick changes to a quote can arrive as one event carrying the latest state.

What sends an event​

An event is sent when a quote you follow (by default, every quote except temp ones) gets a new updated_at. That happens when the quote, or anything under it (its lanes, legs, rates, rate items or cargo), is saved, status changes included: see Detecting changed quotes. Some internal writes, such as the quote's PDF being generated again, move updated_at with no visible change, so an event's quote can look the same as before.

These never send an event:

  • Recording a push. Record Push Status calls, including those that set external_quote_number, don't move updated_at, so your own writes never come back to you as events.
  • Values worked out when the quote is read, such as rate totals that depend on your company's rate-item settings, and each rate item's cargowise_charge_code. An event carries their values as of sending, but a change to them alone sends nothing.
  • Deleting a quote. There's no deletion event, and a quote deleted before its event is sent is skipped.
  • Anything before the endpoint was set up. New endpoints aren't sent past changes: load your existing quotes with List Quotes (see Setting up an endpoint).

Reconciling with polling​

Webhooks can miss things: Starboard stops retrying an event after about two days, an endpoint can be disabled, and deletions and values worked out when the quote is read never send an event. So once a day, or as often as you need, run the incremental sync with List Quotes and updated_since, from your previous run's mark minus the 2-minute overlap, and re-process any quote whose updated_at differs from the one you hold. To find deleted quotes, see Keeping a copy of won quotes.

Rotating the signing secret​

Rotate the endpoint's signing secret if it may have leaked, or if it's lost: with self-serve, your admins rotate it under Settings → Developer settings; otherwise, ask your Starboard team. The new secret is shown once. For the next 24 hours, every delivery carries two signatures in webhook-signature: one made with the new secret and one with the old. Deploy the new secret within those 24 hours; after that, only the new secret's signature is sent. Only two secrets sign at a time: rotating again within the 24 hours retires the oldest one at once.

The libraries and the Apex class above accept a request when any of its signatures matches, so verifying with either secret passes during those 24 hours, without a code change.

Testing and troubleshooting​

  • Test event. With self-serve, your admins send a webhook.test event to your endpoint from Settings → Developer settings, one at a time; otherwise, ask your Starboard team to send one. It's signed like any other event, so it tests your signature check too. It's sent once and never retried. It can go to a disabled endpoint, for example to check it before turning it back on, but not while deliveries are paused.
  • Delivery log. Starboard keeps each event and delivery attempt, with the status code, the error and the first 1 KB of your response, for at least 30 days (90 days for events that failed), and can look up why a delivery failed. Your admins can see the last 20 deliveries under Settings → Developer settings.
  • Resending. With self-serve, your admins can resend a delivery from that list; otherwise, ask your Starboard team to resend an event. A resent event keeps its webhook-id, so your deduplication still applies.

Common causes of failed deliveries:

SymptomLikely cause
Every signature check failsThe body was parsed and serialized again before verifying; the secret was used as text instead of base64-decoding the part after whsec_; or it isn't the endpoint's current secret (for example, one a rotation retired).
Signature checks fail although the secret is rightYour server's clock is more than 5 minutes off.
Deliveries time outYour endpoint processes the event before responding. Respond first, then process.
A 3xx in the delivery logThe URL redirects (for example, from http to https, or to add a trailing slash). Set the final URL instead.
Starboard can't save the endpointThe URL isn't https://, has a username or password in it (use Basic authentication instead), or points to localhost or a private IP address.
Every delivery fails before reaching youYour host name resolves to a private IP address. Starboard sends only to hosts whose addresses are all public.