Market4 tells your server when something happens, with a signed HTTP POST. Every delivery names the event and carries a signature, so you can prove it came from us and that it is not a replay of an older one.
An endpoint belongs to one app and receives only the events you pick. Register it in the panel or over the REST API — the two do the same thing.
Open your app’s Webhooks section, paste the URL of your receiver, tick the events you want and press Add endpoint. The same screen pauses an endpoint, changes its events, rotates its secret and deletes it.
Use an API key with webhooks:read to list, and webhooks:write to create, change, rotate or delete. $API_URL is your API server origin — see the REST API reference. Endpoint URLs must use https.
curl -X POST "$API_URL/apps/$APP_ID/webhooks" \
-H "Authorization: Bearer $LASTMARKET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/last-market",
"events": ["post.published", "feedback.created"]
}'
# The response carries the raw signing secret. This is the only time you see it:
# { "endpoint": { "id": "…", "secret": "whsec_ab12…" }, "secret": "whsec_ab12…full…" }The signing secret is shown exactly once
POST /apps/:appId/webhooks and POST /webhooks/:id/rotate-secret and never again — every other response, and the panel’s own list, shows a masked preview like whsec_ab12…. Put it in your receiver’s configuration straight away. If you lose it, rotate to get a new one; rotating takes effect immediately, so deploy the new secret first.# List your endpoints. Secrets come back masked.
curl "$API_URL/apps/$APP_ID/webhooks" \
-H "Authorization: Bearer $LASTMARKET_API_KEY"
# Change the URL, the events, or stop deliveries without deleting the endpoint.
curl -X PATCH "$API_URL/webhooks/$ENDPOINT_ID" \
-H "Authorization: Bearer $LASTMARKET_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
# Replace the signing secret. Returns a new raw secret, once.
curl -X POST "$API_URL/webhooks/$ENDPOINT_ID/rotate-secret" \
-H "Authorization: Bearer $LASTMARKET_API_KEY"
# Remove the endpoint.
curl -X DELETE "$API_URL/webhooks/$ENDPOINT_ID" \
-H "Authorization: Bearer $LASTMARKET_API_KEY"| Event | Sent when |
|---|---|
post.published | A social post went out |
post.failed | A social post could not be published |
feedback.created | Somebody sent a request, suggestion or complaint |
changelog.published | A changelog entry was published |
email.sent | A subscriber email blast finished |
analytics.synced | An analytics snapshot completed |
approval.pending | An action is waiting for someone to approve it |
Every delivery is an HTTP POST with a JSON body and two headers:
| Header | Contains |
|---|---|
X-LastMarket-Event | The event name, for example post.published |
X-LastMarket-Signature | t=<unix seconds>,v1=<hex hmac> |
The signature is Stripe-style: v1 is HMAC-SHA256(secret, `${t}.${rawBody}`) in lowercase hex. Because the timestamp t is signed along with the body, a captured request stops being usable the moment it ages past your tolerance window — five minutes, in the code below.
const { createHmac, timingSafeEqual } = require('node:crypto');
const TOLERANCE_SECONDS = 300; // reject signatures older or newer than 5 minutes
function verifySignature(secret, rawBody, signatureHeader) {
if (!signatureHeader) return false;
// Header format: t=<unix seconds>,v1=<hex hmac>
let timestamp;
let signature;
for (const part of signatureHeader.split(',')) {
const [key, value] = part.split('=');
if (key === 't') timestamp = Number(value);
if (key === 'v1') signature = value;
}
if (!Number.isFinite(timestamp) || !signature) return false;
// Replay defence: the timestamp is part of the signed material.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison.
const a = Buffer.from(signature, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}const express = require('express');
const app = express();
// Verify against the exact raw bytes. Re-serializing parsed JSON produces a
// different string, and verification will fail for every delivery.
app.post(
'/webhooks/last-market',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifySignature(
process.env.LASTMARKET_WEBHOOK_SECRET,
rawBody,
req.get('x-lastmarket-signature'),
);
if (!ok) return res.status(400).send('invalid signature');
const event = req.get('x-lastmarket-event'); // e.g. "post.published"
const payload = JSON.parse(rawBody);
// …handle the event…
res.sendStatus(200);
},
);Any 2xx counts as delivered. Anything else is retried with exponential backoff, up to five attempts in total; after the fifth the delivery is marked exhausted and we stop.
We record every attempt, so “my endpoint is not receiving events” is an answerable question. Open your app’s Webhooks section and press Deliveries on the endpoint. You get the attempt log — time, event, status, attempt count, the HTTP status your server returned last and the last error. Filter by status, page through with Newer and Older, and open a row to read the exact payload we sent.
| Status | Meaning |
|---|---|
PENDING | Queued, or waiting for its next retry. We have not tried it yet. |
DELIVERED | Your endpoint answered with a 2xx. Done. |
FAILED | The last attempt failed — a non-2xx, a timeout or a connection error — but attempts remain and a retry is scheduled. |
EXHAUSTED | All five attempts failed. We will not retry, and that event is gone for this endpoint. Fix the receiver, then re-trigger whatever produced it if you need the data. |
Both routes need the webhooks:read scope.
# The delivery log of one endpoint — newest first, paged.
# Filters: status=PENDING|DELIVERED|FAILED|EXHAUSTED, event=<event name>,
# limit (default 50, max 200), offset.
curl "$API_URL/webhooks/$ENDPOINT_ID/deliveries?status=EXHAUSTED&limit=20" \
-H "Authorization: Bearer $LASTMARKET_API_KEY"
# {
# "deliveries": [
# {
# "id": "dlv_…", "event": "post.published", "status": "EXHAUSTED",
# "attemptCount": 5, "lastResponseCode": 502, "lastError": "HTTP 502",
# "nextRetryAt": null, "createdAt": "2026-01-01T12:00:00.000Z",
# "payloadPreview": "{\"id\":\"dlv_…\",\"event\":\"post.pub…",
# "payloadTruncated": true
# }
# ],
# "total": 37, "limit": 20, "offset": 0
# }
# The list carries only the first 500 characters of each payload.
# Fetch one delivery to read the body we actually sent, in full:
curl "$API_URL/webhook-deliveries/$DELIVERY_ID" \
-H "Authorization: Bearer $LASTMARKET_API_KEY"