# Microsoft Teams integration

> Announce query requests in a Teams channel and wire approvals through an HMAC-verified action endpoint with single-use action tokens.

- Documentation: Guides
- Updated: 2026-10-25
- Source: https://queryproxy.com/docs/teams-integration/
- Language: en-US
- Author: Muhammet Şafak

---
The Teams integration has two halves: **announcements** into a channel via a
Workflows (or legacy connector) webhook, and an **action endpoint** your automation (an outgoing
webhook or a Power Automate flow) can call to approve or reject.

## Announcements

1. Create a **Workflows** (Power Automate) webhook for your Teams channel
   and copy its URL. A
   URL on `*.powerplatform.com` or `*.logic.azure.com` receives an Adaptive
   Card. Legacy Office 365 connector URLs (`*.webhook.office.com`; an
   `outlook.office.com` URL only after you add that host to
   `QUERYPROXY_CHAT_WEBHOOK_ALLOWED_HOSTS`) still receive a MessageCard, but Microsoft has retired
   Office 365 Connectors: its
   [retirement notice](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)
   (April 14, 2026 update) says they no longer function after the rollout of
   May 18 to May 22, 2026. Use a Workflows URL.
2. In QueryProxy, open **ChatOps**. A DBA pastes the webhook URL and sets the
   enabled flag; the **HMAC secret** (any strong random string — you'll use the
   same secret for the action endpoint below) can only be set by a **system
   administrator**. Both are stored encrypted. An integration cannot be saved
   without a secret, so the first save is an administrator's. The webhook URL
   must be `https://` and its host must be on the allowlist (`hooks.slack.com`,
   `*.webhook.office.com`, `*.logic.azure.com` and `*.powerplatform.com` by default; the list is shared
   across chat providers; extend it with `QUERYPROXY_CHAT_WEBHOOK_ALLOWED_HOSTS`).

> **Breaking change — the signing secret is administrator-only.** From this
> version a DBA can no longer enter or rotate it, and rotating an existing
> secret requires proving the current one. An HMAC proves that the caller knows
> the secret, not that a particular person approved: anyone who could *choose*
> the secret could sign a call approving their own request in a colleague's
> name, and the audit trail would record the colleague. On an existing install,
> rotate the secret once as an administrator after upgrading.

**Removing** an integration is administrator-only for the same reason: putting
it back needs a signing secret only an administrator can set. To switch the
integration off, a DBA unticks the **Enabled** flag and saves.

New requests are posted as a card with the requester, connection, statement
count, a SQL preview and a **Review in QueryProxy** link. The preview
is cut at 400 characters and, by default, shows string and number literals as
`?`; `QUERYPROXY_CHAT_INCLUDE_SQL` switches it to `full` or `none`. Decisions are
announced back to the channel as they happen.

On a Workflows URL the card is an Adaptive Card 1.5 with a fact list (requester,
connection, statements), the SQL preview in a monospace text run (not parsed as
markdown, so `a*b, c*d` arrives as written) and an **Open URL** action. The
body is the Workflows "post card" message: `type: message` with the card in
`attachments[0].content`.

> **Breaking for custom flows.** A flow on a `logic.azure.com` URL that reads
> MessageCard fields (`title`, `summary`, `sections[0].facts`) now reads them
> empty. Read the Adaptive Card from `attachments[0].content`, or pass the body
> on unchanged. The `queryproxy` envelope is unchanged and stays at the top
> level.

Each card also carries a single-use **action token** for that request, in a
`queryproxy` envelope at the top level of the payload:

```json
{
  "queryproxy": { "request_id": 123, "action_token": "…" },
  "type": "message",
  "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "content": { "type": "AdaptiveCard", "version": "1.5" } }]
}
```

On a legacy connector URL the same envelope sits next to `"type": "MessageCard"`.

Teams renderers ignore the unknown key, so the token stays out of the visible
card; your automation reads it there and echoes it back to the action endpoint.

## The action endpoint

To approve or reject *from* Teams-side automation, call:

```
POST https://your-queryproxy-host/webhooks/teams/actions
X-QueryProxy-Timestamp: <unix seconds>
Authorization: HMAC <base64(HMAC_SHA256("{timestamp}:{raw_body}", secret))>
Content-Type: application/json
```

```json
{
  "action": "approve",
  "request_id": 123,
  "actor_id": "<AAD object id>",
  "token": "<action token>",
  "reason": "optional — used for reject"
}
```

- `action` — `approve` or `reject`.
- `request_id` — the request number from the announcement card.
- `actor_id` — the Azure AD **object id** of the approver. QueryProxy resolves
  it to a user through the **Teams ID** mapping an admin sets in
  **Teams & Users → Manage users** (an unlinked id is rejected with `422`); it is never resolved by a
  self-declared email. The resolved user must also belong to the integration's
  team (a system admin always passes this check); otherwise the call is
  refused with `403` before it ever reaches the policy. The usual rules still
  apply: they need the DBA role in the request's team and cannot decide on
  their own request. As on the web, an admin may override both rules, and an
  admin's approval that does so is flagged in the audit log.
- `token` — **required**: the single-use action token from the card's
  `queryproxy` envelope. It is bound to that one request, expires after **24
  hours** and is burned on the first decision (only once that decision is
  actually recorded, so a call the policy turns away does not use it up), so a captured call cannot be
  replayed and the shared secret alone cannot conjure a decision. A call without
  a live token (missing, expired or already spent) is refused with `422` and a
  deliberately vague message.
- `reason` — optional for `reject`; defaults to "Rejected via Teams by
  &lt;name&gt;". For `approve` it is required whenever the approval is an admin
  **override** (the approver is not a DBA of the team, or decides their own
  request): without a reason (1 to 1000 characters) the call is refused with
  `422` and the token is not spent. The reason is stored in the audit log.

> **Breaking change — `token` is mandatory.** Teams automations built against
> earlier versions must start reading it from the card's `queryproxy` envelope
> and sending it, or their calls will be refused. Requests announced before this
> version have no token at all: decide those in the web UI.

The signature is computed over `"{timestamp}:{raw_body}"` (the same
timestamped scheme as [Slack](/docs/slack-integration/)), base64-encoded, in
the `Authorization: HMAC …` header. A Power Automate flow can compute this
header directly.

The HMAC key is the secret as stored, **unless the secret is valid base64**: then
QueryProxy decodes it and signs with the decoded bytes (the convention Teams
outgoing webhooks use). Most purely alphanumeric strings are valid base64, so
sign with the decoded bytes on your side — or include a character outside the
base64 alphabet (such as `-` or `!`) to have the secret used as-is.

## Security

- The `X-QueryProxy-Timestamp` header is required and must be within **±5
  minutes** of server time, so a captured request cannot be replayed later.
- An invalid or missing HMAC (or a stale/absent timestamp) returns `401` and
  changes nothing.
- The action token makes every call answer one specific announcement, once. The
  HMAC alone proves that the caller holds the shared secret — never which person
  is acting — which is also why the secret is administrator-only.
- The approver must be pre-linked through the **Teams ID** mapping and must be a
  member of the integration's team (or a system admin) — the shared secret
  alone cannot act as an arbitrary user.
- Responses are JSON (`{"ok": true|false, "message": "…"}`); authorization
  failures return `403` with the reason, an invalid token or unlinked identity
  returns `422`, and an unknown request id or one outside the integration's
  team returns `404`.
- The endpoint is rate-limited like every webhook in QueryProxy.

## Reminders and expired buttons

A request that stays `pending` for `QUERYPROXY_PENDING_REMIND_AFTER` hours
(default `4`) gets one reminder, with a fresh action token, for the channel and
in the portal. Requests never expire. A decision call that carries an expired
token answers that the action has expired and points to the newer message.

> Full interactive Adaptive Card buttons (as with
> [Slack](/docs/slack-integration/)) require a Teams bot registration and
> are on the roadmap; the action endpoint covers approvals-from-Teams today.
