Gareth Wilson Gareth Wilson

Guide to Azure DevOps Webhooks: Features and Best Practices

Published


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): static key:value lines 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

FeatureDetails
Where to configureProject settings > Service hooks, by a Project Collection Administrator or Project Administrator
SubscriptionOne trigger per subscription, with optional filters such as branch, pipeline or area path
Method and formatPOST with a JSON body
Event identifiereventType in the body
Payload sizeControlled by Resource details to send, Messages to send and Detailed messages to send (All, Minimal or None). The limit is 2 MB
AuthenticationBasic credentials or static HTTP headers. No signature
Transient retries408, 502, 503 and 504: up to eight retries over about three minutes
Other failures410 disables the subscription. Any other error puts it on probation
ObservabilitySeven days of activity per subscription, with full request and response history
RestrictionsNo 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 UIeventType
Code pushedgit.push
Pull request createdgit.pullrequest.created
Pull request updatedgit.pullrequest.updated
Pull request merge attemptedgit.pullrequest.merged
Pull request commented onms.vss-code.git-pullrequest-comment-event
Work item createdworkitem.created
Work item updatedworkitem.updated
Work item commented onworkitem.commented
Build completedbuild.complete
Run state changedms.vss-pipelines.run-state-changed-event
Run stage waiting for approvalms.vss-pipelinechecks-events.approval-pending
Release deployment completedms.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:

  • publisherId tells you which part of Azure DevOps raised the event, for example tfs, rm, pipelines or distributedtask.
  • resource has a different shape for each event, and resourceVersion names the version of that shape.
  • With Resource details to send set to Minimal, resource carries 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.complete and ms.azure-devops-release.deployment-approval-pending-event are 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

  1. Choose a username and a long random password, for example with openssl rand -hex 32.
  2. In your project, go to Project settings > Service hooks and select Create subscription.
  3. Choose Web Hooks, then select the trigger and any filters.
  4. 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.
  5. Choose how much to send under Resource details to send, Messages to send and Detailed messages to send.
  6. 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 503 rather than 500 for 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 410 from 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 eventType and resourceContainers.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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.