How webhooks work
A webhook is an address Reploya calls when something happens. Reploya always makes the call; nothing calls into Reploya, so a webhook opens no way into your organization.
- Who manages them: owners and admins (the
tenant.managepermission) add, edit, pause, enable, test and delete webhooks. Every member sees the list (name, host, format, events, status) and the delivery log, never the full address or the secret. - How many: up to 10 webhooks per organization, each with its own format and events.
- When: every minute Reploya collects what happened and sends one message per webhook and event type. A message usually arrives one to three minutes after the event. Webhooks are not real time.
- From when: a webhook only reports events after it was created or last enabled. Nothing from before, and nothing from while it was paused or disabled, is sent later.
Create a webhook
- In the dashboard, open External access, then Webhooks, and choose Add webhook.
- Name: say where it posts, for example "IT team channel" (1 to 80 characters, unique in your organization).
- Format: JSON for your own system, Microsoft Teams or Slack for a channel (see Formats).
- URL: the address to post to. For Teams, add a Workflows webhook to the channel and paste its URL. For Slack, add an incoming webhook to a channel and paste its URL. The URL is stored encrypted and never shown again; the list shows its host only.
- Events: pick one or more of the five events.
- Save. For a JSON webhook the dialog now shows the signing secret (it starts with
whsec_) once, with a copy button. Store it now; it is not shown again. Teams and Slack webhooks have no secret. - Open the row's menu and choose Send test event to check it works (see Test event).
Events
Each webhook subscribes to one or more event types. The type is the type field of a JSON message and its X-Reploya-Event header.
| Type | Shown as | Sent when |
|---|---|---|
approval_waiting | Version waits for approval | A new version waits for someone to approve it. A version that is approved or declined before the message goes out is left out. |
build_failed | Build failed | Packaging a version failed. |
delivery_failed | Delivery failed | A release could not be assigned in Intune, for one group or for a ring. |
rollout_halted | Rollout halted | A ring rollout was halted and waits for a person. |
release_completed | Release completed | A release reached its devices: a single target release once it is assigned, a ring rollout once its last ring is assigned. |
Grouping
Messages are grouped, so 30 versions released at once are one channel post, not 30. Each minute, a webhook gets at most one message per event type, listing every event of that minute, oldest first. A message lists at most 50 items; moreCount ("and N more" in Teams and Slack) counts the rest, and the summary counts them all. A system reading JSON should therefore handle a list of items, not one event per request.
Formats
Every format carries the same facts: a summary, your organization, and per item the app (its id, name and platform), the version, the reason for a failure, and a link into the dashboard. Links need a sign in, so they are safe in a channel. A message never holds a person's name or email, a tenant or group id, or a token.
JSON
A versioned body, signed (see Verify the signature). reason appears for build_failed and delivery_failed only. test is true for a test event. Fields may be added later within version: 1; ignore fields you do not know.
{
"version": 1,
"deliveryId": "019a0b6e-41f2-7c3d-9a51-6d2f0e8b7c14/build_failed/2026-10-20T09:15:00.000Z",
"type": "build_failed",
"test": false,
"sentAt": "2026-10-20T09:15:02.000Z",
"organization": {
"id": "019a0b6e-3c11-7e42-8b0a-55e1c9d2f003",
"name": "Contoso"
},
"summary": "1 build failed",
"url": "https://app.reploya.com/dashboard/builds",
"items": [
{
"eventId": "019a0b6d-f7a8-7b10-8c3e-2a9d41e6b5f0",
"occurredAt": "2026-10-20T09:12:40.000Z",
"app": {
"id": "019a0b6c-2e95-7a4f-b1d3-7f80c4a9e261",
"name": "Notepad++",
"platform": "windows"
},
"version": "8.7.1",
"reason": "The package could not be uploaded to Intune.",
"url": "https://app.reploya.com/dashboard/builds/019a0b6d-e1c4-7d92-a6f5-30b8e2c71d4a"
}
],
"moreCount": 0
}The wire form is compact JSON on one line; the examples here are indented for reading.
Microsoft Teams
An Adaptive Card for a Teams Workflows webhook: the summary as its title, your organization, one linked line per item, and an "Open in Reploya" button for the list.
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": null,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "2 versions wait for approval",
"weight": "Bolder",
"size": "Medium",
"wrap": true
},
{
"type": "TextBlock",
"text": "Contoso",
"isSubtle": true,
"spacing": "None",
"wrap": true
},
{
"type": "TextBlock",
"text": "[7-Zip 24.09](https://app.reploya.com/dashboard/approvals/019a0b6d-a3f0-7e55-b4c2-8d7e1a6f9b03) · Windows",
"wrap": true
},
{
"type": "TextBlock",
"text": "[Google Chrome 141.0.7390.66](https://app.reploya.com/dashboard/approvals/019a0b6d-b8a1-7c63-9d15-4f0b2e7a8c39) · macOS",
"wrap": true
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "Open in Reploya",
"url": "https://app.reploya.com/dashboard/approvals"
}
]
}
}
]
}Slack
A message for a Slack incoming webhook: the summary in bold, one line per item with its link, and "Open in Reploya".
{
"text": "2 versions wait for approval (Contoso)",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*2 versions wait for approval*\nContoso"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "• <https://app.reploya.com/dashboard/approvals/019a0b6d-a3f0-7e55-b4c2-8d7e1a6f9b03|7-Zip 24.09> · Windows\n• <https://app.reploya.com/dashboard/approvals/019a0b6d-b8a1-7c63-9d15-4f0b2e7a8c39|Google Chrome 141.0.7390.66> · macOS"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "<https://app.reploya.com/dashboard/approvals|Open in Reploya>"
}
]
}
]
}Teams and Slack sometimes answer 200 with an error in the body. Reploya cannot see that, so if a channel stays quiet, check the channel's webhook in Teams or Slack too.
Request headers
Every request is a POST with these headers. Only JSON webhooks carry the X-Reploya-* headers.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | REPLOYA-Webhooks/1 |
X-Reploya-Event | The event type, or test for a test event. |
X-Reploya-Delivery | The message's delivery id, also deliveryId in the body. It stays the same on every retry of one message, so use it to ignore a message you already handled. |
X-Reploya-Timestamp | When this attempt was sent, in Unix seconds. |
X-Reploya-Signature | v1= followed by the hex HMAC SHA-256 of the timestamp and the body. |
POST /your/path HTTP/1.1 Host: hooks.example.com Content-Type: application/json User-Agent: REPLOYA-Webhooks/1 X-Reploya-Event: build_failed X-Reploya-Delivery: 019a0b6e-41f2-7c3d-9a51-6d2f0e8b7c14/build_failed/2026-10-20T09:15:00.000Z X-Reploya-Timestamp: 1792487702 X-Reploya-Signature: v1=2cb95c4d09bfa9383e5c49fba9ae2f57f3c29efd1169435233f273201099a946
Verify the signature
Anyone who knows your URL could post to it. The signature proves a request came from Reploya and was not changed. Compute
v1=hex( HMAC_SHA256( secret, "<X-Reploya-Timestamp>.<raw body>" ) )
and compare it with X-Reploya-Signature in constant time. Use the whole secret as the key, including whsec_. Sign the body exactly as it arrived, before any JSON parsing. Refuse a timestamp more than a few minutes away from your clock, so an old request cannot be replayed.
import { createHmac, timingSafeEqual } from "node:crypto";
// The secret shown once in Reploya, including its "whsec_" prefix.
const SECRET = process.env.REPLOYA_WEBHOOK_SECRET;
/**
* rawBody: the request body exactly as received, as a string.
* headers: the request headers, names in lower case.
*/
export function isFromReploya(rawBody, headers) {
const timestamp = headers["x-reploya-timestamp"];
const signature = headers["x-reploya-signature"] ?? "";
// Refuse old requests, so a captured one cannot be replayed later.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || !(age <= 300)) return false;
const expected =
"v1=" +
createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Rotating the secret replaces it at once, with no overlap: update your receiver right after you rotate.
Address rules
Reploya only posts to public addresses. A URL is accepted when it
- starts with
https://and uses port 443 (or none); - has a host name, not an IP address;
- has no user name or password in it, and at most 2048 characters;
- is not
localhost, not a single label name, and does not end in.local,.internalor.localhost.
Before each request, the host name is resolved and every address must be public. A host that resolves to a private, loopback, link local or other reserved address is refused as Address not allowed, without a request. Redirects are never followed. If your endpoint accepts only known addresses, contact us for the address Reploya sends from.
Retries and failures
- Any
2xxanswer counts as delivered. Answer quickly: a request times out after 10 seconds. - A timeout, a connection, DNS or certificate error, a redirect, or any other status is retried 5 times, starting 30 seconds later and doubling the wait each time, over about 16 minutes in all. Every retry has the same delivery id; its timestamp and signature are new, and an approval decided meanwhile may have dropped out.
410 Gone, an address that is not allowed, or stored settings that cannot be read fail at once, without retries.
Auto disable
A message that still fails after its retries counts as one failure; a delivered message resets the count. After 10 failed messages in a row, or at once on 410 Gone, Reploya disables the webhook. The list then shows Disabled after failures, and every owner and admin gets one email. Fix the endpoint, then choose Enable: the count starts at zero, and events from while it was disabled are not sent.
Delivery log
Recent deliveries in a webhook's menu shows its newest 50 attempts, tests included, newest first: time, event, item count, attempt, result, status code and duration. Every member can open it. It never holds the URL or the body. A failed attempt names its cause:
| Shown as | Meaning |
|---|---|
| Timed out | No answer within 10 seconds. |
| Host not found | The host name did not resolve. |
| Could not connect | The connection was refused or dropped. |
| Certificate error | The certificate is invalid for the host. |
| Redirect not followed | The endpoint answered with a redirect, which is not followed. |
| HTTP <code> | The endpoint answered with this status, not 2xx. |
| Address not allowed | The host resolved to a private or reserved address. |
| Stored settings unreadable, recreate the webhook | The stored URL or secret can no longer be read. Delete and recreate the webhook. |
Test event
Send test event posts one message of type test right away, with "test": true and one sample item ("Example app" 1.0.0 on Windows), formatted and signed like any other message. The dialog shows the result and status code within about 11 seconds, and the attempt appears in the delivery log.
A test is not retried and never changes the failure count or the status. Each webhook allows 10 tests per hour.
Pause, rotate and delete
- Edit changes name, format, URL and events. Leave the URL empty to keep the current one. Switching to JSON creates a new secret, shown once; switching away from JSON deletes it. Editing a disabled webhook does not enable it.
- Pause stops messages until you choose Enable. Events in between are not sent later.
- Rotate secret (JSON only) replaces the secret at once and shows the new one once.
- Delete removes the webhook and its delivery log.
Every change is recorded in the dashboard's Activity, without the URL or the secret.
Recipe: n8n
An n8n flow that receives Reploya messages and checks their signature:
- Add a Webhook trigger node. Set HTTP Method to
POSTand Respond to Immediately, so Reploya gets its200before the 10 second timeout, however long the flow runs. - Copy the node's Production URL (not the test URL) and activate the workflow. In Reploya, add a webhook with format JSON and that URL, and store the secret in an n8n credential or variable.
- Add a Crypto node: action Hmac, type SHA256, encoding HEX, your secret, property name
expected, and as value:Crypto node, value {{ $json.headers['x-reploya-timestamp'] }}.{{ JSON.stringify($json.body) }} - Add an If node that continues only when
v1={{ $json.expected }}equals{{ $json.headers['x-reploya-signature'] }}, and stops the flow otherwise. - Use
{{ $json.body.items }}from there, for example to open one ticket per item, and skip adeliveryIdyou already handled.
Re serialising the parsed body matches the signature because Reploya sends compact JSON. If you prefer the raw body, turn on the Webhook node's Raw Body option and sign that instead, as in the Node.js example.
Want to ask Reploya for details after an event? See the API docs.

