Webhooks
The events Footer365 posts to your endpoints, and how to check each post came from us.
A webhook is an address of yours that Footer365 posts to when something changes, so your system hears about it straight away instead of asking again and again. An organisation adds its endpoints in the customer portal under Developers > Webhooks; a reseller or distributor adds its own in the reseller portal under Account > Webhooks.
Events
| Event | For | Sent when |
|---|---|---|
user.changed |
An organisation | A person's details changed: by a sync from Microsoft 365, an import, the portal, the API, a staff request approved, or the Outlook add-in. |
template.published |
An organisation | A template's live version changed: it was saved while not in use, a draft was published (at once, on approval or at its set time), or an earlier version was restored. Saving a draft of a template in use does not raise it. |
campaign.started |
An organisation | A campaign started running, on its start date or when it was turned on or resumed. |
campaign.ended |
An organisation | A campaign ended, on its end date or when it was ended by hand. |
campaign.clicked |
An organisation | Someone clicked a link in one of its campaign banners. Sent every 15 minutes for the clicks made since the last run, one event per click. Clicks by mail scanners are never sent. A reseller's endpoints cannot take it, because clicks are the organisation's own data. |
customer.setup_completed |
A reseller or distributor | One of its customers finished setting up: mail flow is in place, or the customer chose to add signatures with the Outlook add-in only. A distributor hears about its resellers' customers too. |
webhook.test |
Anyone | Send a test event was pressed on the endpoint. It goes to the endpoint whatever events it takes. |
An endpoint receives only the events ticked for it. More events may be added later; ignore any type you do not recognise.
What a delivery looks like
POST https://hooks.example.com/footer365
Content-Type: application/json; charset=utf-8
User-Agent: Footer365-Webhooks/1.0
Footer365-Event: user.changed
Footer365-Delivery: 7d1c2f0e-5b1a-4a0e-9e44-1f0c6b3a2d19
Footer365-Signature: t=1792396500,v1=5f2b1c...e9a0
{
"id": "0b8f3c52-9d1e-4f6a-8c27-3e5a1d9b7f40",
"type": "user.changed",
"createdAt": "2026-10-19T09:15:00Z",
"data": { "userId": "b6c1...", "email": "[email protected]", "changed": ["mobilePhone", "jobTitle"] }
}
id is the event's own id, the same at every endpoint it went to. Footer365-Delivery is the delivery's id, the same on every retry of it, so a post you have already handled can be recognised and skipped.
An event names what changed, never the values. A post that reached the wrong place gives away no one's details; your system reads the values through the API with its key, such as GET /v1/users/{user} after a user.changed. The one exception is campaign.clicked, which carries the organisations the message went to as email domains, because there is nothing to read them back from. It never carries a recipient's email address, the clicker's IP address or their browser.
| Event | data |
|---|---|
user.changed |
userId, email, and changed: the fields that changed, such as jobTitle, mobilePhone or photo, and your own fields by their merge tag starting with a lower-case letter, such as workingHours. |
template.published |
templateId, name, and versionId, the version now live. |
campaign.started, campaign.ended |
campaignId, name, status, and trigger: manual when a person or a key changed it, schedule when its own dates did. |
campaign.clicked |
campaignId, campaignName, linkId, the banner link clicked (null for a click on a campaign made before each link was counted), clickedAt, senderId, the person in your directory who sent the message, the id the people calls take (null when the sending address does not belong to a person in your directory), and recipientDomains: the outside organisations the message went to, each once, never your own domains, and empty when the message is not on record as having gone outside. The event's id is the click's own id, so the same click sent twice is recognisably one. |
customer.setup_completed |
customerId, the id the customer calls take, microsoftTenantId, name and primaryDomain. |
webhook.test |
endpointId and a message. |
Checking the signature
Every post is signed with the endpoint's secret, which starts whsec_ and is shown once, when the endpoint is added or its secret rolled. Footer365-Signature holds the time it was signed, t, in Unix seconds, and v1, the hex HMAC-SHA256 of the time, a full stop and the body, keyed with the whole secret as UTF-8. Check it against the body exactly as it arrived, before parsing it, and refuse a time more than five minutes from your own clock, so a post cannot be sent again later.
In C#:
static bool IsFromFooter365(string secret, string header, string body)
{
var parts = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var t) || !long.TryParse(t, out var time) || !parts.TryGetValue("v1", out var v1))
return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - time) > 300)
return false;
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(t + "." + body));
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(Convert.ToHexStringLower(mac)), Encoding.ASCII.GetBytes(v1));
}
In Node.js:
const crypto = require('crypto');
function isFromFooter365(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)));
const time = Number(parts.t);
if (!time || !parts.v1 || Math.abs(Date.now() / 1000 - time) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Rolling the secret makes a new one at once, and every post from then on is signed with it, so give your system the new secret straight away.
Answering, retries and turning off
- Answer with any
2xxstatus within 10 seconds. Do the work afterwards, rather than before answering. The body of your answer is not read or stored. - A redirect is not followed and counts as a failure. Use the address the redirect points to.
- A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes and 2 hours, five tries in all.
- Every failed try counts towards the endpoint's failures in a row, and one success sets them back to nought. The portal shows an endpoint as Failing from 10. At 20 failures in a row the endpoint is turned off and the account's contacts are emailed. Events raised while it is off are not sent later, even once it is turned on again.
- Deliveries can arrive more than once and out of order. Use
Footer365-Deliveryto skip one you have handled, and read the current values through the API rather than trusting the order posts arrived in.
Each endpoint's page in the portal shows its deliveries for the last 30 days: when each was sent, its result and how long it took, with the body sent. A failed delivery can be sent again from there, as a new delivery.
Addresses Footer365 will post to
An endpoint must be a public https address. Footer365 refuses an address that is, or whose name resolves to, a private, loopback, link-local or other internal network address, both when the endpoint is saved and again at every delivery, when it connects to the address it has just checked. A name that later starts resolving to an internal address is refused at delivery. Leave any user name and password out of the address: check the signature instead.