Webhook subscriptions expire, tokens rotate, and environment promotions change your callback URLs — and none of it announces itself. This lesson shows you how to build the complete webhook lifecycle in Power Automate: registration, validation, scheduled renewal, and automated health checks that recover from failures before they become outages.

Here's a scenario that plays out in enterprise integration teams more often than anyone likes to admit: your Power Automate flow has been reliably processing incoming GitHub events, Stripe payment notifications, or SAP change documents for weeks. Then one Tuesday morning, a token rotates, the external system's subscription silently expires, or an environment promotion swaps out your callback URL — and the flow stops receiving events. No errors in the run history. No alerts. Just silence. Business-critical data stops flowing, and you find out three days later when a downstream report looks wrong.
Webhook-based integrations are elegant when they work. Instead of polling an API on a schedule and burning API quota, the external system calls you when something happens. That's efficient, low-latency, and production-appropriate. But webhooks introduce a class of infrastructure problem that scheduled flows don't have: your subscription with the external system is a stateful, time-limited resource. It needs to be registered before events flow, renewed before it expires, and re-registered if it breaks. Power Automate's built-in webhook triggers handle some of this automatically for first-party connectors, but the moment you integrate with a third-party API, you're managing that lifecycle yourself.
By the end of this lesson, you'll know how to design and implement the complete webhook lifecycle inside Power Automate — registration, validation, event handling, renewal, and failure recovery — in a way that actually holds up under production conditions.
What you'll learn:
You should already be comfortable with:
Before writing a single action, you need a clear mental model of what you're managing.
A webhook subscription is a contract between two systems: you tell the external service "when event X happens, POST the payload to this URL." The external system stores your URL (and usually an authentication secret) and starts calling it. Your side of the contract is to provide a stable, reachable HTTPS endpoint that responds correctly.
That contract has several failure modes:
Token expiry. Many APIs issue a subscription tied to an OAuth token or API key. When that credential rotates, the subscription either stops working silently or gets explicitly revoked. Stripe, GitHub Apps, and Microsoft Graph all have this behavior in different forms.
Subscription TTL. Services like Microsoft Graph webhooks have hard expiry times — Graph subscriptions for many resource types expire in 3 days for delegated permissions or up to 4230 minutes (about 3 days) for certain application permissions. You must renew before expiry or re-register from scratch.
Endpoint URL changes. When you promote a solution across environments, the Power Automate HTTP trigger URL changes. Your old production webhook registration is now pointing at a dev environment endpoint, or nowhere at all. This is the silent killer of environment promotion.
Rate of change in your own flow. If you disable and re-enable a flow, the HTTP trigger URL stays the same in Power Automate (it's tied to the flow ID, not the run), but some platforms de-register subscriptions when they receive repeated errors. If your flow was broken during a deployment and returned 500s, the subscription might be paused or deleted.
Understanding these modes tells you exactly what your lifecycle management system needs to handle.
A production-grade webhook lifecycle system in Power Automate has four distinct components, each implemented as a separate flow (or scoped section of flows):
This decomposition maps naturally to orchestrating child flows and scoped execution — each component has a single responsibility, which makes testing and debugging tractable.
You'll store subscription state in a Dataverse table called webhook_subscriptions with these columns:
| Column | Type | Purpose |
|---|---|---|
subscription_id |
Text | ID returned by external API |
resource_type |
Text | What resource is subscribed (e.g., "issues", "payment_intent") |
callback_url |
Text | Current registered endpoint URL |
expires_at |
DateTime | When subscription expires |
status |
Choice | Active, Expired, Failed, Renewing |
external_api |
Text | Which service (e.g., "GitHub", "Stripe") |
secret_reference |
Text | Key Vault secret name for HMAC validation |
last_verified_at |
DateTime | Last successful health check |
Note
Storing the secret reference name (not the secret value itself) in Dataverse lets your health check flow retrieve the current secret at runtime from Azure Key Vault. This pattern is covered in depth in the Key Vault and managed identities guide, and it means you never have a credential sitting in a database row.
The registration flow is triggered either manually (for initial setup) or by the health check flow when it detects a missing or failed subscription. Let's use GitHub webhooks as our concrete example — it's a widely-used system with a well-documented registration API and HMAC validation.
Your flow starts by reading the necessary configuration from environment variables and Key Vault. Environment variables hold non-secret config: the GitHub org name, repo name, the list of events to subscribe to, and the Key Vault secret name for the webhook secret.
// Compose: Build Registration Request Body
{
"name": "web",
"active": true,
"events": ["push", "pull_request", "issues"],
"config": {
"url": "@{variables('CallbackURL')}",
"content_type": "json",
"secret": "@{body('Get_Secret_from_KeyVault')['value']}",
"insecure_ssl": "0"
}
}
The CallbackURL variable is set from an environment variable — not hardcoded. This is the single most important discipline for surviving environment promotion. When you deploy to production, the environment variable is updated to the production flow URL, and every registration or renewal uses that value automatically. Without this, you'll spend a Friday afternoon wondering why your production webhook is firing dev events.
Use an HTTP action to POST to GitHub's Hooks API:
Method: POST
URI: https://api.github.com/repos/@{variables('OrgName')}/@{variables('RepoName')}/hooks
Headers:
Authorization: Bearer @{body('Get_GitHub_Token')['value']}
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2022-11-28
User-Agent: PowerAutomate-WebhookManager/1.0
Body: @{outputs('Compose_Registration_Body')}
On success (status 201), parse the response and write to Dataverse:
// Parse the GitHub response
{
"id": 123456789,
"active": true,
"created_at": "2024-01-15T10:30:00Z"
}
Then create a Dataverse row:
subscription_id: string(body('Parse_Hook_Response')['id'])expires_at: GitHub webhook subscriptions don't expire by TTL, but you set this to addDays(utcNow(), 365) for health check scheduling purposescallback_url: the value from your environment variablestatus: Activelast_verified_at: utcNow()Warning
Always run your registration flow in a scope action with Configure run after set to check both success and failure. If the HTTP action fails (rate limit, bad token, network timeout), you want to catch that, log it, and update the subscription record to Failed — not let the flow run complete with a misleading "succeeded" status while you're actually not subscribed.
The receiver is the most performance-sensitive piece of this architecture. Every incoming event hits this flow, and it needs to respond quickly. GitHub, Stripe, and most serious webhook providers will retry delivery if you don't return a 2xx within 10–30 seconds — and they may deactivate your subscription if you consistently fail.
The very first thing your receiver does is validate the incoming signature. Never process payload content before you've verified the request is authentic.
For GitHub, this means:
// Compose: Expected Signature
sha256=@{
base64(
hmacSha256(
base64ToBinary(body('Get_HMAC_Secret')['value']),
triggerBody()
)
)
}
Wait — there's a subtlety here. Power Automate's hmacSha256() function takes the key as a string by default, but your Key Vault secret is base64-encoded binary. You need the raw bytes of the secret for the HMAC calculation to match what GitHub produces. The correct expression:
@{base64(hmacSha256(base64ToBinary(body('Get_HMAC_Secret')['value']), triggerBody()))}
Then compare this against the X-Hub-Signature-256 header from the trigger. Use a Condition action to check equality. If they don't match, use a "Response" action to return HTTP 401 and terminate the flow with Terminate (status: Cancelled). Do not process the payload.
Key insight
Responding with 401 (rather than 200) on invalid signatures tells the external system "I received this but rejected it," which is different from not responding at all. Some platforms will retry on non-2xx responses and eventually suspend your subscription. Consider returning 200 even on validation failure in cases where you'd rather silently drop forged requests without triggering retries — this is a security-vs-reliability trade-off you should make consciously and document.
The second discipline is decoupling receipt from processing. Your receiver should:
In Power Automate, you can implement this by writing the raw event to a Service Bus queue or a Dataverse table, responding 202, and letting a separate flow pick it up asynchronously. This pattern is explored extensively in implementing event-driven automation with Azure Service Bus.
If your events are low-volume and processing is fast (under 5 seconds), you can process inline before responding — but set up the flow's HTTP trigger to enable the "Asynchronous Response" setting in Settings, which gives you up to 120 seconds before Power Automate times out the response automatically.
Real webhook sources send multiple event types to the same endpoint. GitHub sends push, pull_request, issues, release events all to the same URL. You route them with a Switch action on the event type header:
Switch on: @{triggerOutputs()?['headers']?['X-GitHub-Event']}
Case 'push': → Child Flow: Process Push Event
Case 'pull_request': → Child Flow: Process PR Event
Case 'issues': → Child Flow: Process Issue Event
Default: → Compose: Log Unknown Event Type, return 200
Always handle the default case. Unrecognized event types should be logged, not failed — webhook providers sometimes add new event types, and a crash on an unknown type shouldn't take down your subscription.
Renewal is where most teams fail. They build registration and a receiver, ship it, and forget that subscriptions expire. The renewal flow is a scheduled cloud flow that runs daily (or more frequently for short-lived subscriptions like Microsoft Graph).
First, query Dataverse for subscriptions expiring within your renewal window:
// Filter query on webhook_subscriptions table:
expires_at lt '@{addDays(utcNow(), 3)}' and status eq 'Active'
This retrieves any subscription expiring within 3 days. For Microsoft Graph subscriptions (which can expire in as little as 3 days), run this flow every 6 hours and use a 12-hour renewal window. The renewal window should be at least 2× your flow's scheduled interval to guarantee you never miss an expiry.
Use an Apply to Each over the results. Set concurrency to 5 in the loop settings to avoid hammering the external API. For the renewal call, use a PATCH or PUT depending on the API:
// GitHub — update webhook (PUT to modify, or just refresh active state)
Method: PATCH
URI: https://api.github.com/repos/@{variables('OrgName')}/@{variables('RepoName')}/hooks/@{items('Apply_to_each')['subscription_id']}
Body:
{
"active": true,
"config": {
"url": "@{variables('CallbackURL')}",
"secret": "@{body('Get_HMAC_Secret')['value']}"
}
}
For Microsoft Graph, the renewal call is:
Method: PATCH
URI: https://graph.microsoft.com/v1.0/subscriptions/@{items('Apply_to_each')['subscription_id']}
Body:
{
"expirationDateTime": "@{addMinutes(utcNow(), 4229)}"
}
Tip
Always update the callback_url field during renewal, not just the expiry. If an environment promotion changed your callback URL, the renewal call gives you an opportunity to update the external registration to the current correct URL from your environment variable. This is your natural correction mechanism for endpoint drift.
After a successful renewal, update the Dataverse record:
expires_at: new expiry datetime from API responselast_verified_at: utcNow()status: ActiveAfter a failed renewal, update status to Failed and trigger the registration flow to re-subscribe from scratch. You can do this by calling a child flow or by writing a "needs-reregistration" flag that your health check acts on.
The health check is your safety net. Even with renewal in place, subscriptions can fail for reasons outside the renewal window: your endpoint returned consistent errors, the API provider had an outage that corrupted subscription state, or a credential change happened out of band.
The health check calls the external API to list current active subscriptions and compares against your Dataverse registry:
Method: GET
URI: https://api.github.com/repos/@{variables('OrgName')}/@{variables('RepoName')}/hooks
Headers:
Authorization: Bearer @{body('Get_GitHub_Token')['value']}
Parse the response into an array. For each subscription in your Dataverse registry:
subscription_id exists in the API response arrayactive: trueurl in the API response matches your current environment variable CallbackURL// Compose: Check if subscription exists in API response
@{
contains(
string(body('Parse_Hook_List')),
items('Apply_to_each_registry')['subscription_id']
)
}
For a proper check, filter the API response array:
@{
filter(
body('Parse_Hook_List'),
item()?['id'] == int(items('Apply_to_each_registry')['subscription_id'])
and item()?['active'] == true
and item()?['config']?['url'] == variables('CallbackURL')
)
}
If the filter returns an empty array, the subscription is missing or misconfigured. Update the Dataverse record to Failed and trigger re-registration.
Warning
The health check itself can fail if the external API is down or rate-limited. Do not update subscription status to Failed just because the health check couldn't complete. Add a condition: only mark Failed if the HTTP response was 200 with a parseable body, and the subscription was genuinely absent. A 429 or 503 from the API during health check should be logged but should not change your subscription status — the subscription may be perfectly healthy. See handling pagination and throttling when querying large datasets for handling paginated hook lists on repos with many webhooks.
Integrate your health check with your monitoring system. When a subscription is found to be failed or missing, write a telemetry event:
{
"name": "WebhookSubscriptionFailed",
"properties": {
"subscriptionId": "@{items('Apply_to_each')['subscription_id']}",
"externalApi": "@{items('Apply_to_each')['external_api']}",
"resourceType": "@{items('Apply_to_each')['resource_type']}",
"lastVerified": "@{items('Apply_to_each')['last_verified_at']}",
"detectedAt": "@{utcNow()}"
}
}
Send this to Application Insights via HTTP action, and set up an alert rule there for the custom event. The monitoring and alerting system article covers the Application Insights integration in detail.
Credential rotation is the most common cause of webhook subscription failures in production. Your HMAC secret, OAuth token, or API key changes, and suddenly your receiver rejects all incoming events because the signature validation fails.
The clean solution has two parts:
Part 1: Never embed credentials in flow actions. Always fetch from Key Vault at runtime. When the secret rotates, update Key Vault — the flow automatically uses the new value on the next run. This is standard practice as described in the Key Vault and managed identities guide.
Part 2: Implement a grace period during rotation. When you rotate an HMAC secret, there's a window where the external system is using the old secret and your validator is expecting the new one. Design your validator to check against both the current and previous secret during a configurable grace period:
// Condition: Valid Signature
@{
or(
equals(
triggerOutputs()?['headers']?['X-Hub-Signature-256'],
outputs('Compose_Expected_Sig_Current')
),
and(
equals(
triggerOutputs()?['headers']?['X-Hub-Signature-256'],
outputs('Compose_Expected_Sig_Previous')
),
less(
utcNow(),
addHours(variables('RotationStartTime'), 24)
)
)
)
}
Store the rotation start timestamp in an environment variable or a dedicated Dataverse config row. After the grace period expires, the previous secret check stops being evaluated. This is operationally simple and prevents the "everything breaks at midnight" scenario during planned rotations.
Environment promotion is where many webhook lifecycle systems fall apart. You've built everything in dev, packaged it into a solution, and deployed to production. Now your production flows have new HTTP trigger URLs, but your webhook registrations in GitHub, Stripe, or SAP still point to the dev endpoints.
The complete solution requires:
Never hardcode a callback URL in a registration flow. Define a solution environment variable called WebhookCallbackBaseURL with a default value for dev. When promoting, override it with the production URL in your deployment pipeline.
// In your registration flow:
variables('CallbackURL') = concat(
environmentVariable('WebhookCallbackBaseURL'),
'/api/webhooks/github/receive'
)
Wait — Power Automate HTTP trigger URLs aren't customizable path segments like that. The actual URL is assigned by the platform when the flow is created. So your environment variable stores the complete trigger URL for the receiver flow, not a constructed path.
The practical approach: after deploying to a new environment, capture the HTTP trigger URL of the newly-deployed receiver flow and update the environment variable. This can be automated using the Power Platform API in your deployment pipeline. Then run the registration flow once post-deployment to register with the correct URL. This fits naturally into a post-deployment step in building CI/CD with Azure DevOps.
Before promoting a new version, deregister the old subscription. Add a deregistration step to your deployment runbook:
// Child Flow: Deregister Subscriptions
// Reads active subscriptions from Dataverse
// Calls DELETE on each external subscription
// Updates Dataverse records to Deregistered
// Deployment happens
// Registration flow runs post-deploy
This is cleaner than letting the old subscription dangle and potentially conflict with the new one.
There's a circular dependency: your registration flow needs to know the receiver flow's URL to register it. But the receiver flow URL isn't known until the flow is deployed. Your options:
Key insight
The APIM approach is the only one that completely solves the URL stability problem. Your webhook registration always points to https://api.yourcompany.com/webhooks/github, and APIM routes to whatever Power Automate trigger URL is current. You update the APIM policy as part of deployment, not the external webhook registration. This decouples your webhook subscriptions from your Power Automate infrastructure.
Let's wire up a complete lifecycle for Microsoft Graph mail event subscriptions — a realistic scenario where you want Power Automate to receive notifications when emails matching certain criteria arrive, without polling.
Create a new instant cloud flow triggered by an HTTP request. In the "Request Body JSON Schema," define the shape of a Graph notification payload.
Add a condition at the top of the flow: check if triggerBody()?['value'] is null. If it is, this is the validation handshake — Graph sends a validationToken query parameter when registering, and you must echo it back as text/plain with status 200.
Validation response:
text/plain, Body: @{triggerOutputs()?['queries']?['validationToken']}In the false branch (actual event): validate the client state header, process the notification, return 202.
// Example Graph notification body
{
"value": [
{
"subscriptionId": "abc123",
"changeType": "created",
"clientState": "your-secret-state",
"resource": "users/me/messages/AAMkABC...",
"resourceData": {
"@odata.type": "#microsoft.graph.message",
"id": "AAMkABC..."
}
}
]
}
Create a scheduled flow that triggers once on setup (or trigger manually):
https://graph.microsoft.com/v1.0/subscriptions:{
"changeType": "created",
"notificationUrl": "@{variables('CallbackURL')}",
"resource": "me/mailFolders('Inbox')/messages",
"expirationDateTime": "@{addMinutes(utcNow(), 4229)}",
"clientState": "@{body('Get_Client_State_Secret')['value']}"
}
id and expirationDateTime in Dataverse.Create a recurrence flow running every 6 hours:
expires_at lt '@{addHours(utcNow(), 12)}'{
"expirationDateTime": "@{addMinutes(utcNow(), 4229)}"
}
Run the complete cycle once against a test mailbox. Send yourself a test email and watch the notification arrive at your receiver within seconds. That's the reward for getting lifecycle management right.
Problem: Subscription registers successfully but events never arrive.
Check three things in order: (1) Is the receiver flow turned on? Obvious, but worth verifying. (2) Did the validation handshake succeed? Some platforms (Graph, Twilio) require an immediate validation response during registration — if your flow didn't handle it, the registration may have appeared to succeed while actually failing silently. (3) Is the callback URL reachable from the external internet? Power Automate HTTP trigger URLs are publicly accessible by design, but check if any DLP policies are blocking the HTTP trigger connector in that environment.
Problem: HMAC validation fails for all incoming events.
The most common cause is a whitespace or encoding difference in the secret. Secrets stored in Key Vault are stored as strings — if there's a trailing newline or the secret was base64-encoded before storage, your HMAC calculation will produce a different result than the external system. Retrieve the secret and log its length and first/last few characters (never the full value) to verify it's exactly what you expect.
Problem: Renewal fails with 404 — subscription not found.
The subscription was deleted by the external system, usually because your endpoint returned consistent errors. Check the run history of your receiver flow for the period before the 404. If you see repeated failures, the external system may have deactivated and then deleted the subscription. Your renewal flow should handle 404 as a Re-register condition, not a Failed condition — there's nothing to renew, but there's something to recreate.
Problem: Receiver flow returns 200 but processing actions fail silently.
This happens when you return the 202 before processing finishes and the processing actions have errors that aren't surfaced. Use the run context and trigger metadata patterns to emit a flow run ID in your initial 202 response header (X-Flow-Run-ID: @{workflow().run.id}), then look up that run ID in the flow run history when investigating.
Problem: Environment promotion broke all subscriptions.
This is the scenario we designed against. Your immediate fix: update the environment variable with the new callback URL, then run the registration flow manually (after ensuring the old subscriptions are deregistered — check for duplicate registrations on the external system's admin UI). Long term, add APIM in front of your receivers.
Tip
Build a "subscription audit" report as a scheduled flow that emails the team a weekly summary: how many active subscriptions, when the next expiry is, last health check result, and any subscriptions in Failed state. This makes the invisible visible and catches drift before it becomes an outage. You can combine this with the CoE Toolkit-based auditing patterns for a unified governance view.
Webhook lifecycle management is unglamorous infrastructure work, but it's what separates an integration that survives six months in production from one that needs firefighting every few weeks. The core disciplines are consistent:
From here, consider applying similar lifecycle thinking to other stateful integration resources: Dataverse change tracking subscriptions have their own expiry and reset behaviors, and dead-letter queue handling addresses what happens to events that arrive during a subscription outage and need to be replayed. Both extend the resilient integration patterns you've built here.