# Flaghand from n8n, Make, Zapier and GitHub Actions

Turn a workflow event or a GitHub release into checked drafts and one Flaghand approval request. Receive signed
notifications when the owner decides or a job finishes. No CLI package or community integration is needed.

**These workflows never approve, schedule, post, start a waiting job or send DMs.** `createPost` drafts, checks and
requests approval in one HTTP call. Only the owner approves the exact plan in Flaghand with their passkey.
`post_now` below asks for posting after that approval; it does not post during the automation. Use `mode: "approve"`
for an approval-only decision, or `mode: "schedule"` with an offset-bearing `post_at` for a proposed scheduled time.

## Prepare once

1. The owner connects the intended platform account in the desk and creates a workspace token in Settings › Agents.
   Use `read`, `drafts` and `request-approval`; add `media` only for uploads, and explicitly add `webhooks` if this token
   will register receivers. Existing tokens and OAuth connector tokens do not gain `webhooks` automatically.
2. Store the token in your tool's credential/secret store, never in the workflow's JSON, source code or logs. Read
   `GET https://app.flaghand.com/api/v1/me` with `Authorization: Bearer <token>` for the workspace id, then
   `GET https://app.flaghand.com/api/v1/workspaces/<workspace>/connections`. Choose the intended **active** connection's `id`.
3. Register the event receiver below **before** creating the approval request. A subscription receives future events
   only. If registered late, reconcile the current drafts and jobs immediately; notifications are history, not state.
4. Persist a source-event key and the exact outgoing JSON before the HTTP call. Reuse both after a network failure or
   workflow rerun. A new UUID on every attempt defeats idempotency. Use a new key for a genuinely new plan.

In the requests below, substitute `${FLAGHAND_TOKEN}` from credentials, `${FLAGHAND_WORKSPACE}` and
`${FLAGHAND_CONNECTION}` from the reads above, and `${SOURCE_EVENT_KEY}` from the triggering event's stable identity
(for example `release:owner/repo:1234`). The `${…}` notation is a placeholder, not each tool's expression syntax.
Dynamic text must be JSON-encoded by the tool's JSON body builder, not concatenated into a raw JSON string.

An HTTP 200 is not enough: branch on **`ok === true`** and a non-null `approval_url`. Give the owner only that product
approval link, not an approval button in another tool. When `ok` is false, the drafts were saved but approval was not
requested: read `checks`, fix those same `draft_id`/`rev` values and send a new request with a new key. Do not create
another copy. See [the one-call loop and fixes](https://app.flaghand.com/docs/agents.md#3-post).
A completed retry has `Idempotent-Replayed: true`. A changed body with the same key returns
`idempotency_key_reused`; an interrupted request can return a conflict, in which case read the drafts before doing
anything new. Keep the same token for a retry: idempotency is scoped to the workspace and caller.

## n8n: HTTP Request

Use a trigger with a stable event id, then **HTTP Request**: Method **POST**, URL from the first line below,
Authentication **Generic Credential Type › Header Auth** (`Authorization`, value `Bearer <token>`), Send Headers
**on** (`Content-Type` and `Idempotency-Key` below), Send Body **on**, Body Content Type **JSON**. Supply the object
below using the JSON body editor, mapping the connection and event key with n8n expressions. Save the key upstream,
not an execution id that changes on a retry. The exact wire request is:

<!-- example:n8n-create -->
```http
POST https://app.flaghand.com/api/v1/workspaces/${FLAGHAND_WORKSPACE}/posts
Authorization: Bearer ${FLAGHAND_TOKEN}
Content-Type: application/json
Idempotency-Key: n8n:${SOURCE_EVENT_KEY}

{"drafts":[{"kind":"x_post","connection_id":"${FLAGHAND_CONNECTION}","mode":"post_now","body":{"text":"We shipped a clearer weekly view."}}],"note":"One X post by the connected account after your approval; no hand steps."}
```

Follow with an **If** node on the response's `ok`, not just the HTTP node's success. Keep HTTP errors on the error
branch. The true branch can retain the returned draft ids and product approval URL; the false branch fixes existing
drafts. [HTTP Request reference](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/).

## Make: HTTP

Use your source trigger, then **HTTP › Make a request**: Method **POST**, the URL below, the token in the HTTP
module's credential/keychain facility as the `Authorization` header, and the other two headers shown. Choose
**application/json** for the body and use its JSON/data-structure builder (or JSON › Create JSON) to encode dynamic
values; enable response parsing. Persist the source id and outgoing object before retries. The wire request is:

<!-- example:make-create -->
```http
POST https://app.flaghand.com/api/v1/workspaces/${FLAGHAND_WORKSPACE}/posts
Authorization: Bearer ${FLAGHAND_TOKEN}
Content-Type: application/json
Idempotency-Key: make:${SOURCE_EVENT_KEY}

{"drafts":[{"kind":"x_post","connection_id":"${FLAGHAND_CONNECTION}","mode":"post_now","body":{"text":"We shipped a clearer weekly view."}}],"note":"One X post by the connected account after your approval; no hand steps."}
```

Put a filter after HTTP requiring response `ok` to be true; handle a parsed `ok: false` separately from HTTP failures.
Do not let a Make retry rebuild different text under the old key.
[HTTP reference](https://apps.make.com/http).

## Zapier: Webhooks

Use your source trigger, then **Webhooks by Zapier › Custom Request**: Method **POST**, URL below, Data the exact JSON
body below, and headers `Authorization`, `Content-Type` and `Idempotency-Key` as shown. Do not use a form-encoded POST
or wrap the body in a `data` property. Build dynamic bodies in a preceding **Code by Zapier › Run JavaScript** step
with `JSON.stringify`; map that string to Data. Keep the token in a managed secret/credential source, not a literal
in a shared Zap; treat task-history access as credential access if a step maps a token into headers. The wire request is:

<!-- example:zapier-create -->
```http
POST https://app.flaghand.com/api/v1/workspaces/${FLAGHAND_WORKSPACE}/posts
Authorization: Bearer ${FLAGHAND_TOKEN}
Content-Type: application/json
Idempotency-Key: zapier:${SOURCE_EVENT_KEY}

{"drafts":[{"kind":"x_post","connection_id":"${FLAGHAND_CONNECTION}","mode":"post_now","body":{"text":"We shipped a clearer weekly view."}}],"note":"One X post by the connected account after your approval; no hand steps."}
```

Use **Paths** or **Filter by Zapier** on the response's boolean `ok`; a successful HTTP task can still contain a failed
check. Preserve the original source-event id on a task replay.
[Custom Request reference](https://help.zapier.com/hc/en-us/articles/8496326446989-Send-webhooks-in-Zaps).

## Register signed event notifications

For each tool, create its HTTPS receiver first, then make this request with a token explicitly given `webhooks` scope.
`${RECEIVER_URL}` is that tool's receiver URL, not an API URL. Keep the response's `secret` in the receiver's secret
store immediately: it is shown **once**, starts with `whsec_`, and is not the workspace API token. Never put it in the
receiver URL. An owner can register instead; no agent token can grant itself a scope.

<!-- example:register-webhook -->
```http
POST https://app.flaghand.com/api/v1/workspaces/${FLAGHAND_WORKSPACE}/webhooks
Authorization: Bearer ${FLAGHAND_TOKEN}
Content-Type: application/json

{"endpoint_url":"${RECEIVER_URL}","actions":["draft.approval_requested","approval.created","draft.changes_requested","job.posted","job.failed","job.held","job.inbox"]}
```

`draft.approval_requested` means the request was made, **not** that the owner approved it. `approval.created` is a
passkey decision, not proof of posting. Job notifications identify a job; fetch its current result and live check.
For X, report the URLs and read-back comparison. LinkedIn currently has no member-post read-back: report
`check.available: false`, not a verified match. TikTok `job.inbox` means inbox delivery, not a published post.

### Shared signature verifier

Paste this function **above** the adapter for your tool below. It uses Node's built-in `crypto`, not a package.
It checks HMAC-SHA256 over the **untouched UTF-8 body**, with the base64-decoded key after `whsec_`.
Do not use `JSON.stringify(parsedBody)` as the signed body: whitespace, escaping and key order matter.
Headers are case-insensitive; map the three actual incoming values, not similarly named fields from the JSON body.

The adapters expect `oldest_timestamp` and `newest_timestamp` (Unix seconds) from **your receiver's existing replay
and clock-skew policy**, evaluated for this attempt. There is no hardcoded receiver window in these examples.
Configure that policy before accepting events; a timestamp is signed but does not make an old request fresh.
The signature can contain multiple space-separated versioned signatures; any matching `v1` is accepted.

<!-- example:verify-function -->
```js
function verifyFlaghand(input, { createHmac, timingSafeEqual }) {
  const { raw_body, webhook_id, webhook_timestamp, webhook_signature,
    secret, workspace_id } = input;
  const oldest = Number(input.oldest_timestamp), newest = Number(input.newest_timestamp);
  if (typeof raw_body !== 'string' || !webhook_id ||
      !/^\d+$/.test(webhook_timestamp || '') ||
      !/^whsec_[A-Za-z0-9+/]{43}=$/.test(secret || '') ||
      !/^\d+$/.test(String(input.oldest_timestamp ?? '')) ||
      !/^\d+$/.test(String(input.newest_timestamp ?? '')) ||
      !Number.isSafeInteger(oldest) || !Number.isSafeInteger(newest) || oldest > newest) {
    throw new Error('Missing or invalid webhook verification input');
  }
  const timestamp = Number(webhook_timestamp);
  if (!Number.isSafeInteger(timestamp) || timestamp < oldest || timestamp > newest) {
    throw new Error('Webhook timestamp is outside the receiver policy');
  }
  const expected = createHmac('sha256', Buffer.from(secret.slice(6), 'base64'))
    .update(`${webhook_id}.${webhook_timestamp}.${raw_body}`, 'utf8').digest();
  const valid = String(webhook_signature || '').split(/\s+/).some((part) => {
    if (!/^v1,[A-Za-z0-9+/]{43}=$/.test(part)) return false;
    const actual = Buffer.from(part.slice(3), 'base64');
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
  if (!valid) throw new Error('Invalid webhook signature');
  const payload = JSON.parse(raw_body);
  if (!workspace_id || payload.workspace_id !== workspace_id || payload.id !== webhook_id ||
      !payload.event || typeof payload.event.action !== 'string') {
    throw new Error('Webhook identity or workspace mismatch');
  }
  return { delivery_id: webhook_id, workspace_id, action: payload.event.action,
    event_json: JSON.stringify(payload.event) };
}
```

### n8n: Webhook → Code → durable inbox → Respond to Webhook

Create a **Webhook** node for **POST**. In Options, enable **Raw Body**; choose **Using 'Respond to Webhook' Node**
for its response. Register the production webhook URL after activating the workflow, not its temporary test URL.
The raw bytes are binary property `data`; the incoming headers are in the item's `json.headers`. Do **not** use
`json.body` or the base64 binary descriptor as the signed string.

Immediately before Code, use an **Edit Fields** node to assign a trusted object named `receiver` with `secret`,
`workspace_id`, `oldest_timestamp` and `newest_timestamp`. Include **all input fields** in its output (labelled
**Include in Output** or **Include Other Input Fields**, depending on version) and enable **Include Binary Data**.
Resolve `secret` from your managed secret provider and derive timestamp bounds from your policy and the
receiver's current clock, never from incoming fields. Overwrite the entire `receiver` object with that trusted config;
do not accept a sender-provided `receiver`. Keep sensitive execution data out of retained histories.

In **Code**, select **JavaScript** and **Run Once for All Items**. Paste the shared function, then this adapter.
`crypto` is available in n8n Cloud; self-hosted task runners need the documented
`NODE_FUNCTION_ALLOW_BUILTIN=crypto` configuration. No crypto package installation is needed.

<!-- example:n8n-verify -->
```js
const item = $input.first();
const raw = await this.helpers.getBinaryDataBuffer(0, 'data');
const headers = item.json.headers;
const result = verifyFlaghand({
  ...item.json.receiver,
  raw_body: raw.toString('utf8'),
  webhook_id: headers['webhook-id'],
  webhook_timestamp: headers['webhook-timestamp'],
  webhook_signature: headers['webhook-signature'],
}, require('crypto'));
return [{ json: result }];
```

On success, durably accept the result as described below, then **Respond to Webhook** with 204. On verification
failure stop the trusted path and return a non-2xx response. The one item is one request, not a batch of events.
[Webhook options](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/) ·
[Code binary helper](https://docs.n8n.io/code/cookbook/code-node/get-binary-data-buffer/) ·
[Edit Fields options](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.set/) ·
[Self-hosted module access](https://docs.n8n.io/hosting/configuration/configuration-examples/modules-in-code-node/).

### Make: Custom webhook → Make Code → durable inbox → Webhook response

Create **Webhooks › Custom webhook**. In its advanced settings set **JSON pass-through** to **Yes** and
**Get request headers** to **Yes**. Register its HTTPS URL. In **Make Code › Run code**, choose JavaScript and map
these named inputs (the code reads `input`):

- `raw_body`: the webhook's **unparsed JSON text** from JSON pass-through, not a mapped/serialized collection.
- `webhook_id`, `webhook_timestamp`, `webhook_signature`: the incoming `Webhook-Id`, `Webhook-Timestamp`,
  `Webhook-Signature` values from the request-header output.
- `secret`, `workspace_id`: your trusted endpoint signing secret and expected workspace.
- `oldest_timestamp`, `newest_timestamp`: your receiver policy's bounds for the current attempt.

Select those values using the module's output picker after capturing a sample. Make's published reference specifies
the switches but not stable internal output field names for the raw text/headers; do not guess a `body` property.
If the raw output is a parsed object rather than a string, stop and fix JSON pass-through. Make's docs do not promise
byte-for-byte preservation: confirm your scenario accepts an actual signed delivery before trusting this route. If the
body changes, use a verifier in front of Make rather than re-signing or normalizing it. Protect sensitive scenario
history and load secrets from your managed secret provider, not the webhook or a literal in code.
Paste the shared function and this adapter in the same Code module:

<!-- example:make-verify -->
```js
return verifyFlaghand(input, require('crypto'));
```

Make documents Node.js 20.19.4 and `require`, but does not publish a list confirming access to the built-in `crypto`
module. **This adapter is checked under local Node, not verified inside Make.** If your Code runtime refuses `crypto`,
stop; use an HTTPS verifier/queue you control before Make instead. Never replace the signature check with a normal hash
or parsed JSON. No npm dependency is required by the adapter. Make Code availability depends on your Make plan; this
guide does not require purchasing or installing anything to run its local check. Follow verification with your durable
inbox and **Webhooks › Webhook response** (204); wire errors to a non-2xx response, never onward to trusted work.
Make's default immediate acknowledgment is not a verification result, so do not leave it as the only response.
[Webhooks reference](https://apps.make.com/gateway) · [Make Code reference](https://apps.make.com/code).

### Zapier: Catch Raw Hook → Code by Zapier → durable inbox

Create **Webhooks by Zapier › Catch Raw Hook**, not Catch Hook (which parses the payload). Register its HTTPS URL.
Add **Code by Zapier › Run JavaScript** and map **Input Data** with exactly the names in the Make list above.
Use the trigger's raw body text and its three actual request headers via the field picker. `inputData.raw_body` must
be the original string, not JSON produced by another step. Header output labels may be normalized by Zapier; match
them to the actual `Webhook-Id`, `Webhook-Timestamp` and `Webhook-Signature` headers from the captured sample.
Map the secret, expected workspace and policy bounds from trusted configuration, not the hook's JSON. Keep secrets
out of source and shared task history; do not log `inputData`.
Paste the shared function, followed by:

<!-- example:zapier-verify -->
```js
return verifyFlaghand(inputData, require('crypto'));
```

The returned object's `delivery_id`, `workspace_id`, `action` and `event_json` are the only trusted output to use in
later steps. A thrown error must halt the path. Zapier's Node.js runtime provides `crypto`; no package is needed.
Unlike the n8n/Make response paths, Catch Raw Hook acknowledges before the Code step: see the recovery caveat below.
[Catch Raw Hook reference](https://help.zapier.com/hc/en-us/articles/8496288690317-Trigger-Zaps-from-webhooks) ·
[Code by Zapier reference](https://help.zapier.com/hc/en-us/articles/8496310939021-Use-JavaScript-code-in-Zap-workflows).

### Delivery, deduplication and acknowledgments

After verification, durably record `delivery_id` with a unique key **and** enqueue its work in the same database
transaction. A duplicate is a successful no-op. Do not mark it processed in one step and enqueue in another: a crash
between them loses work. In-memory sets, workflow execution ids and signatures are not durable deduplication keys.
Retries use the same delivery id and exact body, but a fresh timestamp/signature. Events can arrive out of order;
fetch the current draft/job rather than assuming an old approval is still valid.

For n8n/Make, place the success response after durable acceptance; verification failures must stop the workflow and
return non-2xx. Do not enable “continue on error” into trusted steps. Flaghand retries non-2xx/network failures on
later cron ticks, without an attempt cutoff, and never follows redirects. List outcomes with
`GET /api/v1/workspaces/<workspace>/webhooks/<endpoint-id>/deliveries` (`webhooks` scope).

Zapier acknowledges the Catch Raw Hook before later Code steps run. A delivered status therefore means **Zapier
accepted the request**, not that verification or downstream work succeeded. Monitor failed Zap tasks and reconcile
through `GET …/events` (`read` scope, `after`/`next_after` cursors), plus current draft/job reads. If you need the sender
to retry until your verification and durable queue succeed, put an HTTPS verifier/queue you control in front of Zapier;
only forward verified events. No notification grants authority to approve or resume a job.

## GitHub Actions: one approval request per published release

Save as `.github/workflows/flaghand-release.yml`. Set repository secret `FLAGHAND_TOKEN` to the workspace token and
repository variables `FLAGHAND_WORKSPACE` and `FLAGHAND_CONNECTION` to the chosen ids. The workflow uses only `curl`
and `jq` on the runner; no checkout, npm install or CLI is needed. It triggers for published releases (including
prereleases), not merely tags. Release text is read from the event file, never interpolated into shell source.

<!-- example:github-workflow -->
```yaml
name: Draft release in Flaghand
on:
  release:
    types: [published]
permissions:
  contents: read
jobs:
  draft:
    runs-on: ubuntu-latest
    env:
      FLAGHAND_URL: https://app.flaghand.com
      FLAGHAND_TOKEN: ${{ secrets.FLAGHAND_TOKEN }}
      FLAGHAND_WORKSPACE: ${{ vars.FLAGHAND_WORKSPACE }}
      FLAGHAND_CONNECTION: ${{ vars.FLAGHAND_CONNECTION }}
    steps:
      - name: Draft, check and request approval (never approve or post)
        shell: bash
        run: |
          set -euo pipefail
          release_id=$(jq -er '.release.id | tostring' "$GITHUB_EVENT_PATH")
          key="github-release:${GITHUB_REPOSITORY}:${release_id}"
          jq -n --slurpfile event "$GITHUB_EVENT_PATH" --arg connection "$FLAGHAND_CONNECTION" '
            {drafts: [{kind: "x_post", connection_id: $connection, mode: "post_now",
              body: {text: ("We released " + $event[0].release.tag_name + ": " + $event[0].release.html_url)}}],
             note: "One release post by the connected account after your approval; no hand steps."}
          ' > post.json
          # Pass credentials through stdin, not process arguments. Do not enable shell tracing.
          status=0
          curl --fail-with-body --silent --show-error --output response.json --config - <<EOF || status=$?
          url = "${FLAGHAND_URL}/api/v1/workspaces/${FLAGHAND_WORKSPACE}/posts"
          request = "POST"
          header = "Authorization: Bearer ${FLAGHAND_TOKEN}"
          header = "Content-Type: application/json"
          header = "Idempotency-Key: ${key}"
          data-binary = "@post.json"
          EOF
          if [ "$status" -ne 0 ]; then
            jq '{error}' response.json
            exit "$status"
          fi
          if ! jq -e '.ok == true and (.approval_url | type == "string")' response.json > /dev/null; then
            jq '{ok, message, drafts: [.drafts[]? | {id, rev}], checks: [.checks[]? | {draft_id, problems}]}' response.json
            exit 1
          fi
          jq '{ok, approval_url, message}' response.json
```

The key is repository + release id, not run id: rerunning the same event replays the same approval request. Changed
release data with the old key is refused; investigate rather than generating a random replacement key. The example
uses the tag and URL, not the entire release notes, to avoid silently cutting content. If that post fails its check,
inspect `response.json`, fix the saved draft by id/rev and request again as described above. Nothing was scheduled.
The product approval URL is the only approval path; the GitHub token has no publishing authority in Flaghand.
GitHub Actions is not an inbound HTTPS webhook receiver: use one of the receivers above (or your own) for signed
outcomes. Do not expose a GitHub repository token to Flaghand or mistake a release trigger for an outcome webhook.

## What was checked

On 2026-10-26, the HTTP requests, the release workflow's actual shell script and all signature adapters were run with
`curl` and Node against the local development desk, real migrations and a synthetic workspace. The test extracts the
examples from this page, checks idempotent retries and failed checks, receives signed deliveries over local HTTP, and
rejects tampered bodies/headers, wrong secrets/workspaces and timestamps outside a configured test policy. No real
approval, job, platform post or credential was used. No n8n, Make or Zapier installation/account was used: their UI,
cloud runtime, credential storage, acknowledgment timing and plan availability were **not** tested end to end.

From a checkout, run the example test with Node 24:

```sh
node --import ./tests/helpers/wasm.mjs --test tests/automations-docs.test.mjs
```

See [agents](https://app.flaghand.com/docs/agents.md), [API reference](https://app.flaghand.com/docs/api.md) and
[sign-in and tokens](https://app.flaghand.com/docs/sign-in-and-tokens.md) for the complete contracts.
