Every ticket integration delivers twice. Build it for that.
Webhooks retry, partners resend, networks drop replies. How to build a ticket bridge that is signed, idempotent, loop free and scoped, with code you can reuse.

Connecting your ticketing system to a partner's portal looks like an afternoon of work: post a webhook when something changes, call their API when they reply. The first demo always works. The trouble starts in week three, when a timeout makes the sender retry, a ticket gets the same reply twice, and two systems start answering each other in a loop at three in the morning. This article is about the four properties that stop that, whichever ticketing platforms sit at each end.
Assume at-least-once delivery
Any sender that retries can deliver the same event more than once. It has no choice: if your server processed the request but the response was lost on the way back, the sender sees a timeout and has to try again. "Exactly once" does not exist on a network. What exists is at-least-once delivery plus a receiver that can safely see the same thing twice.
So the first design rule is that every event carries a stable, unique identifier chosen by the sender, and the receiver records the identifiers it has already applied.
CREATE TABLE applied_events (
event_id TEXT PRIMARY KEY,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
ticket_ref TEXT
);
On every delivery, try to insert the ID first. If the insert fails on the primary key, you have already done this work: answer success and stop. Answering success matters. If you answer with an error, the sender will keep retrying something you already applied.
The event ID must belong to the event, not to the delivery attempt. A reply on ticket 4711 should carry an ID derived from that reply, for example the transaction ID in the source system, so every retry of it carries the same value.
Sign every delivery, and check the clock
Sign every delivery: an HMAC over the raw body with a shared secret, sent in a header, together with a timestamp so a captured request cannot be replayed next week.
import hmac, hashlib, time
SECRET = load_secret() # never in the code repository
MAX_SKEW = 300 # seconds
def verify(raw_body: bytes, ts_header: str, sig_header: str) -> bool:
ts = int(ts_header)
if abs(time.time()-ts) > MAX_SKEW:
return False
signed = f"{ts}.".encode() + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header)
Three details people get wrong. Sign the raw bytes, not a re-serialised JSON object, because two libraries will not serialise the same object identically. Compare with a constant-time function, not ==. And keep a separate secret per partner, so you can rotate one without touching the others.
Break loops on purpose
A bridge copies replies in both directions. If system A forwards its replies to B, and B forwards its replies to A, then a reply that arrived from B is, from A's point of view, a new reply on the ticket. Without a guard it goes straight back. Then B does the same.
The fix is to mark origin and check it before forwarding:
- When you apply an incoming reply, record that it came from the partner, for example in a custom field or a transaction attribute.
- Your outbound trigger skips any transaction marked as partner-originated.
- Do the same on the partner side; do not rely on only one end.
Then test it with a real ticket: send a reply from each side and check that each appears exactly once on the other.
Give the partner one queue and nothing else
The inbound direction usually means the partner calls your ticketing system's API. Give them a dedicated account, never a shared administrator token, and grant rights on one queue only.
In Request Tracker that means a user with no global rights, then on the shared queue only the rights the bridge needs: typically SeeQueue, ShowTicket, CreateTicket, ReplyToTicket and ModifyTicket. Leave out ShowTicketComments, so the partner can never read internal comments, and grant nothing on any other queue. Issue an auth token for that user and hand it to the partner.
Then prove the scope rather than trusting it:
# Should succeed: the shared queue
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: token $PARTNER_TOKEN" \
https://rt.example.com/REST/2.0/queue/Partner-Shared
# Should be refused: any other queue
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: token $PARTNER_TOKEN" \
https://rt.example.com/REST/2.0/queue/General
One Request Tracker specific tip: if RT runs under Apache with mod_fcgid, the Authorization header is not passed to the application unless you add FcgidPassHeader Authorization to the virtual host. Token requests then fail as unauthenticated even though the token is valid. It is worth knowing before you spend an evening on it.
Keep comments out of the bridge
Forward replies; never forward comments. Your engineers need a place to say "this customer has asked this three times" without it appearing in someone else's portal. Test it with a comment containing an obvious marker and search the partner side for it.
Never drop anything silently
When the partner's endpoint is down, your outbound deliveries must wait, not vanish:
- Retry with exponential backoff and jitter, for a bounded time.
- After that, park the event in a dead-letter store with the error.
- Sweep the dead-letter store on a schedule and retry.
- Alert a human if anything stays parked longer than you can tolerate.
- Log every delivery with event ID, ticket, status code and duration.
Do the delivery in a worker, not inside the ticketing system's own request. If the partner is slow, your staff should not be waiting for a page to save.
Two more defensive habits. Verify attachments by checksum on both sides during testing; "it looks the same" is not a test. And cap the size of what you accept: an inline image pasted at full resolution into a ticket can be many times larger than the text around it. Trim or store it separately at the door, rather than letting one heavy ticket slow every page that renders it.
A checklist for your own bridge
- Every event has a sender-chosen ID, and the receiver records applied IDs.
- Every request is signed over the raw body, with a timestamp and a per-partner secret.
- Partner-originated changes are marked and never sent back.
- The partner's token works on one queue and is refused on every other, proven by test.
- Comments never cross; replies always do.
- Retries, dead letters, sweeps and alerts exist, and every delivery is logged.
- Attachments verified by checksum; oversized content handled on arrival.
We build and run bridges like this between Request Tracker and partner portals. If you are planning one, or living with one that misbehaves, we are glad to help.
Published 22 October 2026.
All field notes