coming soon · from Solo
01Concepts
- Connection
- A workspace-level credential to one destination: one Jira site, one Slack webhook, the GitHub App installation, one webhook secret. Encrypted at rest; many channels can share it.
- Channel
- One destination target on one repo, optionally one branch, through a connection: "Jira project DEPS for acme/web, main, P0–P2, on created, reopened and verified".
- Routing rules
- Which tickets a channel carries (priorities, kinds, risk, ready only) and on which events it speaks (created, reopened, verified, status changes, agent notes, comments).
- Link
- The external item that mirrors one ticket on one channel: issue #412, DEPS-12, a Discord message. One per channel and ticket, never two.
- Delivery
- One outbound or inbound unit of work with an idempotency key, attempts, status, latency and a redacted error. The delivery log is the list of these.
- One-way or two-way
- Chat tools and email get cards and mail. Trackers and the webhook also talk back: their comments are mirrored into the discussion and their state changes are noted on the ticket.
Routing rules carry priorities (the wizard offers Red, Amber and Green presets over them), ticket kinds, risk, "ready only" and the triggers. Branch scope is the channel's: leave it empty for every tracked branch, or make one channel per branch. Trackers that support labels get pinwise and the ticket kind.
03State rules
An external change never closes a pinwise ticket. The badge says "closed in Jira, awaiting verification"; the green tick needs a pinwise rescan that finds the upgrade in the lockfile.
Outbound: pinwise status → the mirror
| pinwise | GitHub / GitLab | Jira | Linear | Azure DevOps | Chat / email |
|---|---|---|---|---|---|
open | open | To Do | Backlog / Todo | New | card "open" |
reopened | open | To Do | Backlog / Todo | New | card "reopened" |
in_progress | open + label in progress | In Progress | In Progress | Active | follow-up, if status changes are on |
verifying | open + label verifying | In Review (or In Progress) | In Review | Active | follow-up |
closed_verified | closed (completed) | Done | Done | Closed | card "verified" |
closed_claimed | closed (completed) + comment "closed on pinwise without verification" | Done | Done | Resolved + the same comment | follow-up |
wontfix | closed (not planned) | Won't Do / Canceled | Canceled | Removed | follow-up |
stale | unchanged | unchanged | unchanged | unchanged | nothing |
Defaults per destination; a channel's state map can override any row. Tickets in stale leave the mirror alone.
Inbound: what a change over there does here
- Closed, done or resolved over there
- The ticket is marked "closed externally, awaiting verification" and stays open. With "rescan when they close an item" on, the home branch is rescanned; the ticket closes as closed_verified only if the lockfile reached the target. Otherwise a comment goes back on the mirror saying the upgrade is not in the lockfile yet.
- Canceled or won’t do
- Treated as closed externally: the same badge, the same rescan. It never sets wontfix on its own.
- Reopened after we closed it
- Noted as "reopened externally" on the ticket. No status change: a verified close is evidence. You can reopen from the board as always.
- Moved to in progress
- Shown in the activity feed. No status change: leases belong to agents.
- A comment by someone who is not pinwise
- Mirrored into the discussion as "Jane via Jira", with follows and notifications as for a web comment. Our own comments and anything carrying the footer marker are dropped as echoes.
- The item is deleted
- The link is removed. The next matching event creates a fresh mirror if the channel sends on created.
"Closed externally" clears when the ticket's status changes on our side: verified, reopened by a rescan or won't fix. State loops cannot happen, because inbound never changes the ticket's status.
04Plans
| Plan | Channels | Destinations | Two-way |
|---|---|---|---|
| Free | — | none | no |
| Solo | 2 | Slack, Webhook, Discord, Email, Microsoft Teams | no |
| Studio | 10 | all 10 | yes |
| Agency | 100 | all 10 | yes |
Channels count per workspace while active; paused ones do not. Two-way sync is part of Studio and up: on a lower plan a tracker channel is outbound only. See pricing.
05Setup guides
Settings → Ticket channels → Add channel walks you through destination, connect, target, rules and a test. Every credential is stored encrypted and never shown again; what each destination needs is below.
GitHub Issues
two-wayStudio+One issue per ticket in the repo itself; closes, reopens and comments come back.
You need
- The pinwise GitHub App installed on the repo (it already is, if the repo is scanned)
Access
GitHub App permission Issues: read and write on the repos you picked when installing.
In the wizard
- Pick GitHub Issues. If the App was installed before Issues access existed, GitHub asks you to approve the new permission once; the wizard sends you there and back.
- Choose the repository. Issues must be enabled on it.
- Set the rules and the triggers, send a test (it only reads the repo), save.
What comes back
issues and issue_comment webhooks through the App.
- Each ticket becomes one issue with the labels pinwise and its kind; in progress and verifying are labels too.
- A closed issue marks the ticket “closed in GitHub, awaiting verification”; the ticket itself closes on the next verified rescan.
Slack
outbound onlySolo+A card in a channel when a ticket lands; with the Slack app, replies and ✅ come back.
You need
- An incoming webhook URL for the channel you want the cards in
Access
Slack → Apps → Incoming Webhooks → Add to Slack, pick the channel, copy the URL. No OAuth scopes.
In the wizard
- Pick Slack and paste the webhook URL (hooks.slack.com only).
- Name the channel as Slack shows it, so the row reads "#deps-alerts".
- Set the rules; "Send a test" posts a sample card.
Outbound only
This destination cannot talk to pinwise. Cards and mail are sent; nothing over there changes the ticket.
- Incoming webhooks cannot edit a message, so a status change is a short follow-up card.
- The URL is the credential: it is stored encrypted and never shown again.
Webhook
two-waySolo+Signed JSON to any https endpoint: Zapier, Make, n8n or your own bot. Talks back by contract.
You need
- An https endpoint that accepts JSON
- A way to keep the raw request body for signature checks
Access
Nothing on your side; pinwise signs every delivery with the channel’s signing secret.
In the wizard
- Pick Webhook and paste the URL: https, port 443, a hostname. Private hosts, IP addresses and redirects are refused.
- Name the endpoint. Save; the signing secret is shown once.
- Verify X-Pinwise-Signature with the snippet below, dedupe on the delivery id, answer 2xx within 15 seconds.
- For two-way sync, copy the callback URL and the inbound secret from the done step and sign your callbacks with it.
What comes back
By contract: POST signed state and comment messages to the callback URL. See the contract below.
- The payload is version 1: fields are added, never renamed or removed.
Linear
two-wayStudio+Issues in a team or project; state and comments sync both ways.
You need
- A Linear account that can install OAuth apps in the workspace
Access
OAuth scopes read, write and comments:create, approved once per workspace.
In the wizard
- Pick Linear and continue with Linear; approve the scopes and you come straight back.
- Choose the team, and a project if you want one.
- Set the rules, send a test (it reads the team), save.
What comes back
Issue and Comment webhooks of the pinwise OAuth app, signed with Linear-Signature and a 60-second timestamp check. Nothing to register.
- States map by name: Backlog or Todo, In Progress, In Review, Done, Canceled; override per channel under “Destination states” in the wizard.
Jira Cloud
two-wayStudio+Issues in a project with your workflow; transitions and comments come back.
You need
- A Jira Cloud site and a project
- Either the right to install OAuth apps, or an API token
Access
OAuth 2.0 (3LO) scopes read:jira-work, write:jira-work, manage:jira-webhook and offline_access. Or an Atlassian API token (id.atlassian.com → Security → API tokens) for an account that can create issues and comments in the project.
In the wizard
- Pick Jira Cloud. Continue with Jira for OAuth, or open "Use an API token instead" and enter the site URL, your account email and the token.
- Choose the project and the issue type.
- Set the rules, send a test (it reads the project), save.
- API-token connections only: in Jira go to Settings → System → WebHooks, add a webhook with the per-channel URL from the done step and set its secret to the inbound secret; subscribe to issue updated and comment created.
What comes back
OAuth connections register a dynamic webhook for you and refresh it before Jira’s 30-day expiry. Manual webhooks are verified with X-Hub-Signature; an unsigned call to a channel with a secret is refused.
- Transitions match by status category, so a custom workflow still lands on To Do, In Progress and Done.
Discord
outbound onlySolo+An embed in a channel that updates in place as the ticket moves.
You need
- A webhook URL for the channel
Access
Discord channel → Settings → Integrations → Webhooks → New Webhook, copy the URL. No bot, no scopes.
In the wizard
- Pick Discord and paste the webhook URL (discord.com only).
- Name the channel as Discord shows it.
- Set the rules; "Send a test" posts a sample embed.
Outbound only
This destination cannot talk to pinwise. Cards and mail are sent; nothing over there changes the ticket.
- The embed is edited in place when the ticket moves, so one message per ticket stays current.
Instant mail for the tickets you pick; works for everyone, nothing to install.
You need
- The addresses that should get the mail
Access
Nothing: mail goes out from noreply@pinwise.dev.
In the wizard
- Pick Email and enter the recipients, one per line or comma-separated. Workspace members are trusted; any other address gets one confirmation mail and nothing else until its owner confirms.
- Mail is instant: one mail per ticket as it lands. A per-scan digest (one mail once the scan has been quiet for ten minutes) exists in the API only; the wizard does not offer it yet.
- Set the rules; "Send a test" mails a sample to the confirmed recipients.
Outbound only
This destination cannot talk to pinwise. Cards and mail are sent; nothing over there changes the ticket.
- Each recipient gets at most 20 mails an hour and an unsubscribe link in every mail.
- Replies are not read yet; the mail says so.
Microsoft Teams
outbound onlySolo+An Adaptive Card in a channel through a Workflows webhook.
You need
- A Power Automate "Workflows" webhook for the channel (Office 365 connectors are retired)
Access
In Teams open Workflows, pick "Post to a channel when a webhook request is received", choose the team and channel, copy the URL. The URL carries its own signature.
In the wizard
- Pick Microsoft Teams and paste the workflow URL (*.logic.azure.com or *.api.powerplatform.com).
- Name the channel as Teams shows it.
- Set the rules; "Send a test" posts a sample Adaptive Card.
Outbound only
This destination cannot talk to pinwise. Cards and mail are sent; nothing over there changes the ticket.
- The flow answers without a message id, so a status change is a follow-up card.
GitLab Issues
two-wayStudio+Issues in a GitLab project, for mirrored repos and GitLab CI shops.
You need
- A project or group access token
- For webhooks: Maintainer on the project
Access
Access token with the api scope (Settings → Access tokens), for a role that can create issues and comments and, for two-way sync, register project webhooks (Maintainer).
In the wizard
- Pick GitLab Issues, keep gitlab.com or enter your self-managed host, paste the token.
- Choose the project.
- Set the rules, send a test (it reads the project), save.
What comes back
A project webhook pinwise registers with a per-channel secret token; GitLab echoes it in X-Gitlab-Token and the match is constant time. Issue close and reopen and notes come back.
- Notes written by the token’s bot user and system notes are dropped as echoes.
Azure DevOps Boards
two-wayStudio+Work items in a project; state and comments sync through service hooks.
You need
- Your organization URL (https://dev.azure.com/<org>)
- A personal access token
Access
PAT scopes: Work Items (read & write) and Service Hooks (read, write & manage). PATs expire within a year; set a reminder.
In the wizard
- Pick Azure DevOps Boards, enter the organization URL and the PAT.
- Choose the project and the work item type.
- Set the rules, send a test (it reads the project), save.
What comes back
Two service-hook subscriptions (workitem.updated, workitem.commented) that pinwise creates with basic-auth credentials doubling as the inbound secret, compared in constant time.
- State names come from your process (Agile, Scrum, Basic or custom); type them under “Destination states” in the wizard, or leave them blank for pinwise’s defaults.
06Webhook contract
pinwise POSTs JSON to your URL with Content-Type: application/json and three headers. The body is payload version 1: fields are added, never renamed or removed; a breaking change would ship as version 2 alongside.
| Header | Value |
|---|---|
X-Pinwise-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(signing secret, "<t>.<raw body>")> |
X-Pinwise-Delivery | Delivery id. The same across retries: dedupe on it. |
X-Pinwise-Event | ping, ticket.created, ticket.updated, ticket.state_changed, ticket.comment |
Events
ping- Sent by "Send a test" and when the channel is created. ticket_id and status are null.
ticket.created- A ticket matched the rules for the first time. Carries the full ticket and the footer.
ticket.updated- The ticket text or priority changed. Carries the full ticket and the footer.
ticket.state_changed- The pinwise status moved (verified, reopened, wontfix, …). status is the new one.
ticket.comment- An agent note or a human comment, when the channel sends them. comment has body, author and via.
Payload
{
"version": 1,
"id": "01JDQ4Z6M8R3V3F5K1S0C4YJ7P",
"event": "ticket.created",
"sent_at": "2026-10-29T10:00:00.000Z",
"channel_id": "01JCX2H4N9T6Q7W8E1R2T3Y4U5",
"ticket_id": "01JB7G5V2K4M6N8P0Q2R4S6T8V",
"status": "open",
"ticket": {
"id": "01JB7G5V2K4M6N8P0Q2R4S6T8V",
"display_id": "TDB-0012",
"number": 12,
"title": "Upgrade lodash to 4.17.21 (CVE-2021-23337)",
"body_md": "## Target and why\n\nlodash 4.17.15 → 4.17.21 …",
"status": "open",
"priority": 0,
"kind": "cve",
"risk": "low",
"package": "lodash",
"ecosystem": "npm",
"from_version": "4.17.15",
"target_version": "4.17.21",
"repo": "acme/web",
"branch": "main",
"url": "https://pinwise.dev/app/tickets/01JB7G5V2K4M6N8P0Q2R4S6T8V",
"created_at": "2026-10-29T09:58:41.000Z",
"updated_at": "2026-10-29T09:58:41.000Z"
},
"footer": "---\n**pinwise TDB-0012** · https://pinwise.dev/app/tickets/01JB…\nAI agents: use the pinwise MCP (https://pinwise.dev/docs/mcp) to start, update and complete\nthis ticket on pinwise.dev; this copy is a mirror and closes only when a pinwise rescan verifies\nthe upgrade. `ticket_id: 01JB…`\n<!-- pinwise:ticket=01JB… channel=01JC… -->",
"external_id": null,
"comment": null
}ticket and footer are set for ticket.created andticket.updated; status and ticket_id for every event butping; comment for ticket.comment. external_id is your id once you have answered a create with one, else the pinwise ticket id.
What your receiver must do
- Answer any 2xx within 15 seconds
- Do the work after answering; a slow receiver times out and is retried.
- Reply to ticket.created with your id
- Optional JSON {id, key, url}; pinwise then uses your id as external_id. Otherwise the pinwise ticket id is used.
- 401 or 403 breaks the channel
- Means the secret is wrong. So do 404 and 410. 429 with Retry-After and 5xx are retried with backoff.
- https on port 443, a hostname, no redirects
- IP addresses, private hosts and redirects are refused, on every delivery, not only when you save.
{
"id": "task_8812",
"key": "OPS-77",
"url": "https://tasks.example.com/OPS-77"
}Calling back (two-way)
When two-way sync is on, the channel gets a callback URL and an inbound secret, shown once. Send one message per request, signed the way we sign deliveries.
{"version":1,"id":"evt-1041","type":"state","external_id":"task_8812","state":"Done","actor":"jane"}
{"version":1,"id":"evt-1042","type":"comment","external_id":"task_8812","comment_id":"c-77","body":"Looks good on staging","author":"jane"}- Sign with the inbound secret
- Same scheme as our deliveries: X-Pinwise-Signature over "<t>.<raw body>". The callback URL and the secret are shown once when two-way sync is turned on.
- One message per request
- type "state" with your own state name (add kind when the name is unusual) or type "comment" with comment_id, body and author.
- id is yours and unique
- A repeated id answers 200 and is ignored. Success answers 202; a bad signature or a timestamp older than 5 minutes answers 401.
- A close never closes the ticket
- An external change never closes a pinwise ticket. The badge says "closed in Jira, awaiting verification"; the green tick needs a pinwise rescan that finds the upgrade in the lockfile.
07Verify signatures
Always verify against the raw body bytes, before parsing JSON, with a constant-time compare, and reject timestamps older than five minutes. The same check works for callbacks pinwise receives: swap in the inbound secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPinwise(rawBody, header, secret, toleranceSeconds = 300) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
if (!match) return false;
const [, t, mac] = match;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
return timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(expected, 'hex'));
}
// Express: keep the raw body.
app.post('/pinwise', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyPinwise(req.body, req.get('X-Pinwise-Signature'), process.env.PINWISE_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// dedupe on event.id, then handle event.event
res.sendStatus(204);
});This function is run in our test suite against a signature made the way the API makes it.
import hashlib, hmac, re, time
SIG = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")
def verify_pinwise(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
m = SIG.match(header or "")
if not m:
return False
t, mac = m.group(1), m.group(2)
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, expected)
# Flask: request.get_data() is the raw body.
# if not verify_pinwise(request.get_data(), request.headers.get("X-Pinwise-Signature"), SECRET): abort(401)Signing a callback
import { createHmac } from 'node:crypto';
export async function sendCallback(message) {
const body = JSON.stringify(message);
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac('sha256', process.env.PINWISE_INBOUND_SECRET).update(`${t}.${body}`).digest('hex');
return fetch(process.env.PINWISE_CALLBACK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Pinwise-Signature': `t=${t},v1=${v1}` },
body,
});
}08Zapier and Make
The generic webhook is how pinwise reaches Asana, Trello, Notion and everything else. Both recipes verify the signature first, then create the item with the title, the link and the footer.
Zapier
- Trigger: Webhooks by Zapier → Catch Raw Hook. Paste the hook URL into the pinwise channel and press Send a test; it delivers a ping.
- Action: Code by Zapier → Run JavaScript. Input fields: raw = Raw Body, sig = Headers Http X Pinwise Signature, secret = your signing secret. Paste the code below.
- Filter by Zapier. Continue only if ok is true and event is ticket.created.
- Action: create the item in your tool. Asana, Trello, Notion, …: title as the name, url plus footer in the description so coding agents can find the pinwise ticket.
- Calling back (optional). A second Code step builds and signs the message with the callback helper, then Webhooks by Zapier → Custom Request POSTs it with the X-Pinwise-Signature header.
const crypto = require('crypto');
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(inputData.sig || '');
const ok = !!m && Math.abs(Date.now() / 1000 - Number(m[1])) < 300 &&
crypto.timingSafeEqual(Buffer.from(m[2], 'hex'),
crypto.createHmac('sha256', inputData.secret).update(`${m[1]}.${inputData.raw}`).digest());
const event = ok ? JSON.parse(inputData.raw) : {};
return { ok, event: event.event, title: event.ticket?.title, url: event.ticket?.url, footer: event.footer };Make
- Webhooks → Custom webhook. Under advanced settings enable Get request headers and JSON pass-through, so the raw body is available.
- Set variable: expected. expected = sha256(t + "." + body; "hex"; secret), where t is the part of the x-pinwise-signature header between t= and the comma (get(split(header; ","); 1) and replace), body is the raw value and the third argument makes Make compute the HMAC.verify in your scenario
- Filter. Continue only when expected equals the v1= part and t is within 300 seconds of timestamp. Make’s compare is not constant time: keep the signing secret long and random.verify in your scenario
- JSON → Parse JSON. Parse the body, then route on event.
The Make HMAC step is written from Make's function reference and has not been run end to end by us; check it against a test delivery before trusting it.
09Troubleshooting
Health
| Health | Chip | Meaning |
|---|---|---|
unknown | new | Created, nothing delivered yet. The first success makes it healthy. |
ok | healthy | The last delivery succeeded. |
degraded | retrying | A delivery failed on a network error, timeout, 5xx or 429 and is being retried on the schedule below. |
failing | stopped | Five consecutive dead deliveries, or any auth or invalid-target error: pinwise stops the channel (the chip reads "stopped", the owners get one mail) until you fix the destination and press Test or Resume, or a six-hourly probe succeeds. |
The delivery log
Every channel page lists its deliveries, outbound and inbound, with attempts, latency, the response status and a redacted error. Delivered and skipped rows are kept for 90 days, dead ones for 180.
queued- Waiting for the consumer; usually seconds.
delivering- In flight. Anything stuck here for 30 minutes is re-queued by the hourly catch-up.
delivered- The destination answered 2xx; the link and the health chip are updated.
failed- Failed and scheduled to retry; the row shows the next attempt.
dead- Out of attempts, or an error that never retries (auth, invalid target). Owners can retry by hand.
skipped- Nothing to do: a duplicate, an unknown inbound event, or the channel was paused.
Retries
Network errors, timeouts, 5xx and 429 are retried after 30 s, 2 min, 10 min, 1 h, 6 h, five attempts in all; a 429 with Retry-After waits that long (at most an hour) without counting as an attempt. Auth and invalid-target errors never retry: they stop the channel at once. An hourly catch-up re-queues anything stuck in flight and dispatches for workspaces whose nudge was missed.
Common questions
- The channel shows "stopped"
- Open the channel: the health card names the error kind. Auth means the token or OAuth grant was revoked (reconnect); invalid target means the project, repo or webhook URL is gone (edit the target); after five dead deliveries the last error is shown. Fix the destination, then press Test to resume.
- A ticket was pushed twice
- It should not be: there is one link per channel and ticket, and trackers are searched for the footer marker before an item is created. If you deleted the item over there, the next matching event recreates it. Send the delivery id from the log to support if you see two live items.
- They closed the item but the ticket is still open
- By design. The badge says "closed in Jira, awaiting verification" (or GitHub, Linear, …). Turn on "rescan when they close an item", or request a rescan from the ticket or over MCP. The ticket closes only once the lockfile shows the target.
- Nothing arrives
- Check the rules: the channel only speaks for the priorities, kinds, risks and triggers you picked, and only for its repo and branch. "Send a test" in the wizard posts a sample without touching the rules; the log says whether the destination answered.
- You got "Your Jira channel for acme/web stopped working"
- One mail per break, sent to the workspace owners, with the error kind and what to do. You get "is working again" once it recovers. Channels paused by a plan change get the same mail as paused repos.
- Webhook deliveries answer 401
- The signing secret you verify with is not the one shown when the channel was created. Rotate it from the channel page and update your receiver; a 401 or 403 from your side breaks the channel at once.
- Email recipients never got the mail
- External addresses stay pending until the mailbox owner uses the confirm link; workspace members are trusted. Each address gets at most 20 mails an hour, digest and instant together, and can unsubscribe with one click.