I replaced a ten-minute GitHub polling cron with an event-driven webhook agent so issues trigger a model decision the instant they arrive—no stale reports, no polling, and no server bill. The architecture that survived two broken versions is accept-fast, process-async, then add signature checks, idempotency, and a rate limiter; I built and tested it using MonkeyCode's free model access and its free server option.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Four Stages From GitHub Event to Action
The cron woke every ten minutes, checked for new issues, asked a model to classify them, and wrote a report. Most wakes found nothing. The report was stale by the time I read it. I wanted push instead of poll: a webhook that receives GitHub events the moment they happen, asks a free model what to do, and acts.
The pipeline looks like this:
GitHub → webhook endpoint → signature check → event router → model decision → action
Polling vs webhooks is the first contrast that matters. Polling wastes empty wakes and ships stale reports. Webhooks are fresh, but they introduce a 10-second deadline, at-least-once delivery, and bursts. I ran the endpoint on a free server so I could iterate without a monthly bill.
Stage 1: Return Before the 10-Second Cutoff
A webhook handler must do three things, in this order:
- Verify the signature on the raw body.
- Respond fast enough that GitHub does not time out.
- Defer the model call and the side effects.
GitHub documents timeout and retry behavior in its webhook overview and best practices for using webhooks. GitHub webhooks time out after 10 seconds. A model call can take 5 to 30 seconds. Process synchronously and you get retries that re-run the model.
import hashlib
import hmac
import asyncio
import json
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
@app.post("/webhook")
async def webhook(request: Request):
payload = await request.body()
signature = request.headers.get("X-Hub-Signature-256", "")
expected = "sha256=" + hmac.new(
SECRET.encode(), payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="invalid signature")
event = request.headers.get("X-GitHub-Event", "")
data = json.loads(payload)
asyncio.create_task(process_event(event, data))
return {"status": "accepted"}
The last two lines are the most important. Accept immediately; process in the background. Use hmac.compare_digest, never ==. Follow GitHub's validating webhook deliveries guide so a forged payload never reaches the free model.
Stage 2: Route Noise Away From the Model
Not every event needs a model. A star does not need classification. A docs-branch push does not need a summary. Filter before the expensive call:
HANDLERS = {
"issues": handle_issue,
"pull_request": handle_pr,
"star": handle_star, # log only, no model call
}
async def process_event(event: str, data: dict):
handler = HANDLERS.get(event)
if handler:
await handler(data)
The star handler is a reminder: most webhook traffic is noise. A call per event burns quota on nothing. On free models this matters more than on paid endpoints, because unused calls still consume a shared window.
Stage 3: Decide, Then Validate JSON
For issues, the model decides three fields: priority, area, and whether a reply is warranted. The output is JSON, validated before any action:
def decide_issue(payload: dict) -> dict:
issue = payload["issue"]
text = f"Title: {issue['title']}\nBody: {issue['body'][:2000]}"
response = call_free_model(
system="You classify GitHub issues. Return JSON only.",
user=text,
)
decision = json.loads(response)
validate_decision(decision)
return decision
The validator is non-negotiable. A model that returns {"priority": "urgent"} when the schema says ["p0", "p1", "p2"] is a bug, not a quirk. I treat a failed parse like a failed HTTP call: log it, skip the action, and do not retry blindly.
Stage 4: Keep Actions Conservative
Actions stay small on purpose:
- Add a label.
- Post a comment.
- Or do nothing.
The agent never closes issues, never merges PRs, never writes to the repo without a human in the loop. That constraint is why a free server was acceptable for me—if a restart drops an in-flight task, the worst case is a missing label, not a merged PR.
Three Failure Modes That Broke v1 and v2
The 10-second timeout
A free model call can take 5 to 30 seconds. Synchronous handling turns one slow classify into a retry storm, and every retry spends quota on the same issue.
The fix is the async pattern in Stage 1. The accept response tells GitHub the event was received; the background task does the work. Polling never hit this limit, but it paid with stale data and empty wakes.
Duplicate deliveries
GitHub delivers webhooks at least once. A network blip means the same event arrives twice. Without deduplication, the model classifies the same issue twice—and the two decisions can differ.
processed_ids: set[str] = set()
async def process_event(event: str, data: dict):
delivery_id = str(data.get("delivery_id") or data.get("id"))
if delivery_id in processed_ids:
return
processed_ids.add(delivery_id)
# ... process
An in-memory set works for a single free server. For anything more serious, use SQLite or Redis with a TTL. I started with the set, then moved IDs to SQLite after the first restart replayed a burst. Prefer the X-GitHub-Delivery header when you still have the request object; fall back to the payload id if you do not. Idempotency is not optional once GitHub retries.
The burst problem
Free model endpoints rate-limit differently than paid ones. The limits are often softer—a window you cannot see until you hit it. A popular issue can fire dozens of webhooks in a minute. Without a limiter, you get 429s.
A token bucket handles this:
import time
class TokenBucket:
def __init__(self, capacity: int, refill_per_second: float):
self.capacity = capacity
self.tokens = capacity
self.refill = refill_per_second
self.last = time.monotonic()
def take(self) -> bool:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
When the bucket is empty, send the event to a dead-letter queue instead of dropping it. A human processes the leftovers. That is the difference between a polite agent and one that hammers free models until they 429.
Metrics, Limits, and Who Should Skip This
Instrument the pipeline before you trust it. Four metrics matter:
- Events received vs. events processed. The gap is your failure rate.
- Model call latency. If P95 exceeds 10 seconds, synchronous processing will break.
- 429 rate. A spike means the limiter needs tuning.
- Duplicate rate. GitHub delivers at least once; deduplication should catch the extras.
These four numbers tell you whether the pipeline is healthy. I log them on the same free server that runs the endpoint so a bad deploy shows up in one place.
Skip this pattern if you are in one of these groups:
- Teams that need guaranteed delivery. A free server has no SLA. A restart can lose in-flight events. Free servers are fine for labels and draft comments, not for compliance workflows.
- Anyone handling sensitive data. Free model endpoints may not offer data residency guarantees. Check before sending anything private.
- High-volume repositories. Free tiers are generous—the advertised 10M-token allowance as of August 2026 covers a lot of webhook traffic—but a sustained flood still hits the rate limit.
If you already run webhooks on a paid provider, the patterns transfer. The vendor is not the point. The architecture is.
Takeaway: Ship the Guards, Then Call the Model
The event-driven pattern replaces polling with push, and that changes the failure modes you design for. Signature verification, async processing, idempotency, and rate limiting are the real work. The model is the easy part.
I built this on MonkeyCode's free model access and its free server option. The code is open source—check the current free-tier terms before you depend on them.
If you are still polling GitHub every ten minutes, stand up the endpoint on a free server this week, point a test repo at it, and watch the four metrics for a day. Try the accept-fast pattern on a throwaway repository, then tell me which failure mode hit you first—timeout retries, duplicate deliveries, or a 429 burst. I will keep iterating on free models and free servers as the terms evolve.