Notion to Jira Sync with n8n
TL;DR — Notion → Jira sync is easy if one-way. Two-way needs: stable external ID stored on both sides, “last writer wins” or per-field rules, and idempotent update logic so sync events don’t trigger themselves. Most teams should ship one-way first, add two-way only if needed.
After Jira and Slack, today’s pattern is bidirectional integration. Sync is a classic source of “this seems easy” → “this is a hall of mirrors.” Notion ↔ Jira is the case I keep getting asked about. This post is what I’ve actually shipped.
The use case
Concrete: our retrospectives create action items in Notion (because Notion is where retrospectives live). Action items need to land in Jira (because Jira is where engineering work tracks). Today: copy-paste. Tomorrow: a Notion item with “create_in_jira” checked → workflow creates the Jira ticket.
Three escalation levels:
- One-way Notion → Jira on flag. Notion item with a checkbox; n8n creates Jira ticket. Easy.
- One-way with status sync. Same, plus when Jira ticket status changes, Notion item updates. Mostly easy.
- Two-way bidirectional. Edit either side, the other follows. Hard.
I’ll cover the first two well and warn about the third.
One-way: Notion → Jira
Workflow shape:
[Trigger: Notion Database polling, every 5 min]
↓
[Filter: items where "Create in Jira" is checked AND "Jira Key" is empty]
↓
[Jira: Create Issue from Notion fields]
↓
[Notion: Update item — set "Jira Key" to new key, uncheck "Create in Jira"]
The “Jira Key is empty” filter is what prevents duplicates. We store Jira’s returned key in a Notion property; if it’s set, this item has already been synced.
n8n has a built-in Notion node. Configure:
Resource: Database Page
Operation: Get All
Database ID: <your retro action-items db>
Filters:
- "Create in Jira" equals true
- "Jira Key" is empty
Polling interval depends on tolerance: 5 minutes is fine for most retros; for “immediately on click” you’d need Notion webhooks (only available in their Public API for some cases).
For each filtered item, create Jira issue:
Node: Jira
Resource: Issue
Operation: Create
Project: PROJ
Issue Type: Task
Summary: {{ $json.properties.Name.title[0].plain_text }}
Description: {{ $json.properties.Description.rich_text[0]?.plain_text ?? '' }}
Labels: ["from-retro", "notion-synced"]
Then update Notion:
Node: Notion
Resource: Database Page
Operation: Update
Page ID: {{ $json.id }}
Properties:
"Jira Key": {{ $('Jira').first().json.key }} // text
"Create in Jira": false // checkbox
"Jira URL": https://yourorg.atlassian.net/browse/{{ $('Jira').first().json.key }} // url
Done. The “Create in Jira” checkbox unchecks itself; next poll skips this item.
One-way with Jira status echoed back to Notion
Adds a second workflow:
[Trigger: Jira Webhook on issue_updated]
↓
[Filter: issue has label "notion-synced"]
↓
[Notion: Find database page by Jira Key property]
↓
[Notion: Update page — sync status field]
The Jira webhook fires on every update. Filter by the notion-synced label so you only react to ones that originated from Notion.
The Notion lookup is the tricky part — there’s no built-in “find by property value” operation in n8n’s Notion node. Use the HTTP node:
Method: POST
URL: https://api.notion.com/v1/databases/<db-id>/query
Headers:
Authorization: Bearer {{ $credentials.notion.apiKey }}
Notion-Version: 2022-02-22
Body (JSON):
{
"filter": {
"property": "Jira Key",
"rich_text": { "equals": "{{ $json.issue.key }}" }
}
}
Returns the matching Notion page (or empty if no match). Update with another Notion node.
Two-way sync: where it gets hard
The temptation: same workflow, both directions. Notion → Jira on Notion change; Jira → Notion on Jira change. What happens:
- User edits Notion item’s “Description”
- Notion → Jira workflow updates Jira’s description
- Jira webhook fires “issue_updated”
- Jira → Notion workflow detects change, updates Notion’s description
- Notion’s update triggers… well, fortunately the Notion node doesn’t fire a “page updated” webhook to your own workflow, BUT if you were polling, you’d re-detect the change. Loop.
To prevent loops, three patterns:
Pattern A — Source-of-truth flag per field. Each synced field has an “owner”: Description is owned by Notion, Status is owned by Jira. The sync workflow only writes fields it owns. No bidirectional contention.
Pattern B — Update fingerprint. Both sides compute a content hash. On change, compare to the last-synced hash. If they match, the change came from a sync (no-op).
Pattern C — Sync-source marker. Add a property “Last synced from: notion@2022-05-16T10:00Z”. When syncing back, check this; if recent, skip.
Pattern A is simplest and most maintainable. Pick which fields each system owns up front.
Idempotency
Critical for safety. Both directions should be idempotent:
- Re-running the workflow with the same input should produce the same end state, not duplicate items.
- The “Jira Key is empty” filter in our example provides idempotency: once a Jira key is stored on the Notion item, re-running doesn’t create another Jira ticket.
If you find yourself writing “delete duplicate Jira tickets” cleanup logic, your workflow isn’t idempotent. Fix the upstream condition.
Rate limits — both APIs
- Notion: 3 requests/second average. 429 on burst.
- Jira Cloud: 10 req/sec, burst ~100.
For sync workflows hitting both, throttle to the slower (Notion). For bulk migrations, run in batches with explicit Wait nodes (~400ms between calls).
Real-world numbers
For our retro action items sync:
- ~10 new items per week after a retrospective
- Workflow runs every 5 minutes
- Average n8n execution time: 800ms
- Notion API calls: ~150/week
- Jira API calls: ~30/week
- Zero issues in three months of operation
The numbers are tiny. The value is real: nobody copy-pastes retrospective action items into Jira anymore.
Common Pitfalls
No external-ID property on both sides. Without storing Jira key on Notion (and Notion ID on Jira if going both ways), you have no way to know what’s already synced. Add the property first.
Polling too frequently. Notion’s rate limit is real. 5 min is fine; 30 sec is asking for 429s.
Updating fields a human is actively typing in. Race conditions in the UI. Either widen the polling interval, or use a “sync now” trigger (Notion checkbox / Jira label).
Sync writing back the value it just read. The pattern A separation of ownership prevents this. Without it, every sync triggers another sync.
Treating sync errors as silent failures. A failed sync = a ticket that doesn’t exist in Jira but the user thinks it does. Alert on failures (covered Friday).
Webhooks that don’t include enough data. Some webhook payloads are minimal (“issue updated”). You may need to call the API to get full state. Bake the round-trip into the workflow shape.
Two-way sync as the first attempt. Ship one-way. Live with it for a month. Decide if you really need two-way.
Wrapping Up
Notion → Jira is straightforward as long as you have an external ID anchor and stay one-way. Two-way is sometimes worth it but always more work than it looks. Ship the simpler version; revisit. Wednesday: webhooks 101 — the protocol that underpins every event-driven workflow in this month’s posts.