Guide to Azure DevOps Webhooks: Features and Best Practices
Azure DevOps webhooks push events from your projects to your own services: code is pushed, a pull request is opened or merged, a work item changes, a pipeline run finishes, or a release waits for approval. They're built on service hooks, and they let chat bots, ticketing systems, deployment tools and audit logs react without polling the Azure DevOps REST API.
This guide covers how Azure DevOps service hook subscriptions work, the main events and their payloads, how to protect your endpoint with Basic credentials, how Azure DevOps handles failures, and the best practices for production.
What are Azure DevOps webhooks?
Service hooks connect an event in an Azure DevOps project to an action on another service. Microsoft ships consumers for Slack, Teams, Jenkins, Azure Service Bus and others. The Web Hooks consumer (consumer ID webHooks, action ID httpRequest) is the generic one: it sends an HTTP POST with a JSON representation of the event to any public URL.
Each subscription pairs one trigger, such as Code pushed or Work item updated, with one action. To receive several kinds of event at the same endpoint, you create one subscription per trigger with the same URL.
Azure DevOps doesn't sign webhooks. The Web Hooks action offers two ways to authenticate:
- Basic authentication (
basicAuthCredentials): a username and password sent as standard HTTP Basic credentials. Microsoft requires HTTPS for this. - HTTP headers (
httpHeaders): statickey:valuelines added to every request. Microsoft notes that these values "are viewable by anyone who has access to the service hook subscription."
Azure DevOps webhook features
| Feature | Details |
|---|---|
| Where to configure | Project settings > Service hooks, by a Project Collection Administrator or Project Administrator |
| Subscription | One trigger per subscription, with optional filters such as branch, pipeline or area path |
| Method and format | POST with a JSON body |
| Event identifier | eventType in the body |
| Payload size | Controlled by Resource details to send, Messages to send and Detailed messages to send (All, Minimal or None). The limit is 2 MB |
| Authentication | Basic credentials or static HTTP headers. No signature |
| Transient retries | 408, 502, 503 and 504: up to eight retries over about three minutes |
| Other failures | 410 disables the subscription. Any other error puts it on probation |
| Observability | Seven days of activity per subscription, with full request and response history |
| Restrictions | No loopback or special-range addresses. Use HTTPS |
Common events
Every event section in Microsoft's reference names an event ID, which arrives as eventType. The ones most integrations start with:
| Trigger in the UI | eventType |
|---|---|
| Code pushed | git.push |
| Pull request created | git.pullrequest.created |
| Pull request updated | git.pullrequest.updated |
| Pull request merge attempted | git.pullrequest.merged |
| Pull request commented on | ms.vss-code.git-pullrequest-comment-event |
| Work item created | workitem.created |
| Work item updated | workitem.updated |
| Work item commented on | workitem.commented |
| Build completed | build.complete |
| Run state changed | ms.vss-pipelines.run-state-changed-event |
| Run stage waiting for approval | ms.vss-pipelinechecks-events.approval-pending |
| Release deployment completed | ms.azure-devops-release.deployment-completed-event |
There are more for TFVC check-ins, repository lifecycle (git.repo.created, git.repo.deleted, git.repo.forked, git.repo.renamed, git.repo.statuschanged), work item deletion and restore, release creation and approvals, pipeline stages and jobs, agent pools, service connections and Advanced Security alerts.
Every payload shares one envelope. Here is a trimmed git.push:
{
"id": "a0a0a0a0-bbbb-cccc-dddd-e1e1e1e1e1e1",
"eventType": "git.push",
"publisherId": "tfs",
"message": {
"text": "Jamal Hartnett pushed updates to branch main of repository Fabrikam-Fiber-Git.",
"html": "...",
"markdown": "..."
},
"detailedMessage": { "text": "...", "html": "...", "markdown": "..." },
"resource": {
"refUpdates": [{ "name": "refs/heads/main", "oldObjectId": "...", "newObjectId": "..." }],
"repository": { "id": "f5f5f5f5-aaaa-bbbb-cccc-d6d6d6d6d6d6", "name": "Fabrikam-Fiber-Git" },
"pushedBy": { "displayName": "Jamal Hartnett" },
"pushId": 14
},
"resourceVersion": "2.0",
"resourceContainers": {
"collection": { "id": "b1b1b1b1-cccc-dddd-eeee-f2f2f2f2f2f2", "baseUrl": "https://dev.azure.com/fabrikam-fiber-inc/" }
},
"createdDate": "2024-09-19T13:03:27.0379153Z"
}
A few things to know about the envelope:
publisherIdtells you which part of Azure DevOps raised the event, for exampletfs,rm,pipelinesordistributedtask.resourcehas a different shape for each event, andresourceVersionnames the version of that shape.- With Resource details to send set to Minimal,
resourcecarries only key fields such as the ID and URL, and your service calls back into the API for the rest. - Microsoft's samples for
build.completeandms.azure-devops-release.deployment-approval-pending-eventare two JSON objects run together, so they don't parse. Don't copy them into test fixtures. Capture a real delivery instead.
Setting up Azure DevOps webhooks
- Choose a username and a long random password, for example with
openssl rand -hex 32. - In your project, go to Project settings > Service hooks and select Create subscription.
- Choose Web Hooks, then select the trigger and any filters.
- On the Action screen, enter your HTTPS endpoint as the URL, and the username and password under Basic authentication. Leave Accept untrusted SSL certificates off outside development.
- Choose how much to send under Resource details to send, Messages to send and Detailed messages to send.
- Select Test, then Finish. Repeat for each trigger you need.
In Hookdeck. Create a source with the Azure DevOps type and enter the same username and password. Use the source URL as the subscription URL. Hookdeck reads the Authorization: Basic header and compares both values. Hookdeck doesn't send a 401 challenge, so the credentials have to arrive on the first request. Use Test in the wizard and check that the request appears in Hookdeck as accepted rather than as failed verification.
The Azure DevOps source type only checks Basic credentials. If your subscriptions use HTTP headers instead, use a Hookdeck source with API key verification. If your service sits behind a VPN, allow Azure DevOps's inbound IP addresses, which Microsoft lists in its networking documentation.
Securing Azure DevOps webhooks
Decode the Authorization header, split it on the first colon, and compare the username and password with your stored values in constant time.
const crypto = require('crypto');
const express = require('express');
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on buffers of different lengths
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyBasicAuth(header, user, pass) {
if (!user || !pass) return false; // fail closed when unconfigured
const match = /^basic\s+(\S+)\s*$/i.exec(header || '');
if (!match) return false;
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
const i = decoded.indexOf(':'); // the password may contain ':'
if (i === -1) return false;
const userOk = safeEqual(decoded.slice(0, i), user);
const passOk = safeEqual(decoded.slice(i + 1), pass);
return userOk && passOk;
}
const app = express();
app.post('/webhooks/azure-devops', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifyBasicAuth(
req.get('authorization'),
process.env.AZURE_DEVOPS_WEBHOOK_USERNAME,
process.env.AZURE_DEVOPS_WEBHOOK_PASSWORD
);
if (!ok) return res.sendStatus(401);
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Route on event.eventType, deduplicate on event.id, process async.
res.sendStatus(200);
});
The same check in Python:
import base64
import binascii
import hmac
import re
def verify_basic_auth(header, user, password) -> bool:
if not user or not password:
return False # fail closed
match = re.fullmatch(r"(?i)basic\s+(\S+)\s*", header or "")
if not match:
return False
try:
decoded = base64.b64decode(match.group(1), validate=True)
except (binascii.Error, ValueError):
return False
received_user, sep, received_pass = decoded.partition(b":")
if not sep:
return False
user_ok = hmac.compare_digest(received_user, user.encode("utf-8"))
pass_ok = hmac.compare_digest(received_pass, password.encode("utf-8"))
return user_ok and pass_ok
Basic credentials prove the sender knows them. They don't prove the body is unchanged, and a captured request can be replayed and still pass, because Azure DevOps sends no signature and no timestamp. Use HTTPS only, keep the password long and random, and treat the payload as a notification: when the data matters, such as the result of a build that gates a deployment, fetch it from the REST API.
Make Azure DevOps webhooks production-ready. Hookdeck Event Gateway checks the Basic credentials on every request, accepts each event straight away, and queues it durably with retries and replay, so your subscriptions stay enabled.
Azure DevOps webhook limitations and pain points
Probation drops new events
The Problem: A short outage or a bug that returns 500 can cost you every event that happens over the next day or more, not only the one that failed.
Why It Happens: Azure DevOps treats any error other than 408, 502, 503, 504 and 410 as an enduring failure, and puts the subscription on probation. "When a subscription is on probation, it loses any new events." It retries the failed notification up to seven times, with backoff growing from 20 minutes to 15 hours, about 36 hours in all. Only a successful response restores the subscription.
Workarounds:
- Return
503rather than500for problems on your side that a retry could fix, so they count as transient. Transient retries only last about three minutes, though. - Watch subscription state on the Service Hooks page, and reconcile through the REST API after an incident.
How Hookdeck Can Help: Hookdeck accepts each event as soon as it arrives, so Azure DevOps sees success and the subscription never goes on probation. Hookdeck then delivers to your service with its own retries, and anything that still fails can be replayed after the fix.
Subscriptions disable themselves
The Problem: Events stop arriving and nothing in your service reports an error.
Why It Happens: Azure DevOps sets a subscription to DisabledBySystem after probation retries run out, disables it at once on a 410 Gone, disables it when its last successful delivery was more than six months ago, and disables it when the user who created it leaves the team.
Workarounds:
- Never return
410from a webhook endpoint unless you mean to unsubscribe. - Create subscriptions with a service account rather than a person's account.
- Alert when the volume of a busy event type drops to zero.
How Hookdeck Can Help: Because Hookdeck always accepts deliveries, failures in your service can't disable the subscription. Hookdeck's issues alert you when deliveries to your service fail, and the event log shows when events from a subscription stop arriving.
One trigger per subscription
The Problem: Covering pushes, pull requests, builds and work items across several projects means managing dozens of subscriptions, each holding its own copy of the credentials.
Why It Happens: Service hooks are configured per project, and each subscription has one trigger.
Workarounds:
- Create subscriptions with the Service Hooks REST API from a script, so the set is reproducible and credential rotation is one run.
- Point them all at one URL and route on
eventTypeandresourceContainers.project.id.
How Hookdeck Can Help: Point every subscription at one Hookdeck source, then use filters on eventType or project to route each kind of event to the service that handles it.
No integrity or replay protection
The Problem: Anyone who learns the credentials can forge an event, and a captured request can be replayed.
Why It Happens: Azure DevOps doesn't sign payloads or include a delivery timestamp you can check. Basic credentials are the same on every request, and credentials sent through HTTP headers are visible to everyone who can see the subscription.
Workarounds:
- Prefer Basic authentication to HTTP headers, use HTTPS, and keep the password long and random.
- Deduplicate on
id, and confirm anything consequential through the REST API.
How Hookdeck Can Help: Hookdeck's Azure DevOps source type checks the Basic username and password on every request. Hookdeck answers Azure DevOps with a 200 either way, then records a request with missing or wrong credentials as failed verification, so it never reaches your destination.
Best practices
Route on eventType
Switch on eventType and acknowledge types you don't handle. Check resourceVersion too, because the resource shape can change between versions.
Deduplicate deliveries
Retries resend the same notification. Use the event's id as an idempotency key. See our guide to webhook idempotency.
Send only what you need
Set Resource details to send to Minimal or None when you'll fetch details from the API anyway. Smaller payloads stay well under the 2 MB limit and expose less data in transit.
Acknowledge fast and process asynchronously
Return 200 as soon as the event is stored, and do the work, such as triggering a deployment or syncing a ticket, in a background job. Errors in your processing then can't push a subscription onto probation. See why to process webhooks asynchronously.
Use the subscription history
The Service Hooks page keeps seven days of activity, with the full request, response and payload for each event. It's the first place to look when deliveries fail.
Conclusion
Azure DevOps sends a JSON POST for each service hook event, protected by Basic credentials rather than a signature. Check those credentials in constant time over HTTPS, route on eventType, deduplicate on id, and avoid error responses that put a subscription on probation, because a subscription on probation loses new events and can end up disabled.
Hookdeck Event Gateway sits in front of your receiver, checks the Basic credentials, accepts every event immediately, and keeps it until your service has processed it.
Get started with Hookdeck for free and handle Azure DevOps webhooks reliably in minutes.