sinpapel-webhooks
Event-driven HTTP communication for sinpapel — outbound webhooks (signal-driven) + inbound receiver framework. HMAC-SHA256 signing Stripe-compatible, pluggable delivery backends (inline / outbox / celery), idempotency dedup. Production-ready sin broker dependency.
Status: v0.2.1 — event catalog expansion + Admin REST API. See CHANGELOG · README en español.
1. Installation
# Core (incluye outbox backend default — DB-backed queue, no broker required)
pip install sinpapel-webhooks
# Con Celery distributed delivery (gated por extra)
pip install "sinpapel-webhooks[celery]"
# Con Admin REST API (Subscriptions CRUD + Deliveries/Events read — v0.2.0)
pip install "sinpapel-webhooks[admin]"
Requirements: Python ≥3.10, Django ≥5.0, sinpapel ≥0.7.0.
2. Quick Start (5 min adoption)
Step 1 — settings.py
INSTALLED_APPS = [
# ... django.contrib + simple_history + sinpapel + sinpapel_drf ...
"sinpapel_webhooks", # auto-discovers <app>/webhooks.py
]
# Outbound (default outbox = DB-backed queue, no broker)
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
# Inbound (per-source secrets)
SINPAPEL_WEBHOOKS_INBOUND_SECRETS = {
"baz": "<32-byte-hex-secret-from-baz-config>",
}
Step 2 — urls.py
from django.urls import include, path
urlpatterns = [
# ... existing patterns ...
path("sinpapel/api/webhooks/", include("sinpapel_webhooks.urls")),
]
Step 3 — myapp/webhooks.py (auto-discovered)
from sinpapel_webhooks import webhook_receiver
@webhook_receiver(source="baz", event="payment.confirmed")
def handle_baz_payment(payload, request):
# Handle inbound webhook from BAZ
return {"acked": True, "pago_id": payload["data"]["pago_id"]}
Step 4 — Migrate + run worker
python manage.py migrate sinpapel_webhooks
python manage.py sinpapel_webhooks_worker # daemon mode
Step 5 — Create outbound subscription (Django admin or shell)
from sinpapel_webhooks.models import WebhookSubscription
WebhookSubscription.objects.create(
name="my-consumer",
url="https://consumer.example.com/webhook",
events=["workflow.transition.completed"],
secret="<32-byte-hex>",
active=True,
)
That's it. Outbound POSTs trigger automáticamente desde sinpapel signals
(workflow transitions, signatures, document changes). Inbound POSTs a
/sinpapel/api/webhooks/in/baz/ invocan tu handler.
3. Settings Reference
| Setting | Default | Purpose |
|---|---|---|
SINPAPEL_WEBHOOKS_BACKEND |
"outbox" |
Delivery backend: "inline" / "outbox" / "celery" / dotted-path |
SINPAPEL_WEBHOOKS_REQUEST_TIMEOUT |
10 |
Seconds for HTTP POST timeout |
SINPAPEL_WEBHOOKS_TIMESTAMP_TOLERANCE |
300 |
Replay protection window (seconds) |
SINPAPEL_WEBHOOKS_MAX_ATTEMPTS |
5 |
Max retry attempts antes de dead_letter |
SINPAPEL_WEBHOOKS_RETRY_BACKOFF |
[60, 300, 1800, 7200, 43200] |
Backoff seconds (1m/5m/30m/2h/12h) |
SINPAPEL_WEBHOOKS_DEAD_LETTER_AFTER_ATTEMPTS |
True |
True → status="dead_letter"; False → "failed" |
SINPAPEL_WEBHOOKS_INBOUND_SECRETS |
{} |
Dict per-source secret: {"source_name": "32-byte-hex"} |
4. Outbound
Sinpapel-webhooks listens a 3 sinpapel domain events automatically:
| Event Type | Trigger |
|---|---|
workflow.transition.completed |
SeguimientoWorkflow created (transition exitosa) |
signature.completed |
RegistroFirma created |
document.uploaded |
InstanciaDocumento created |
Subscription model
WebhookSubscription:
name: human-readable labelurl: target POST URLevents: JSON list of event types subscribedsecret: HMAC-SHA256 secret (32-byte hex recommended)active: enable/disablecreated_by: User FK (audit)history: HistoricalRecords (django-simple-history audit trail)
Payload envelope
{
"event_id": "01HXX5K8N3...",
"event_type": "workflow.transition.completed",
"occurred_at": "2026-05-01T14:22:33.123Z",
"data": {
"solicitud_id": 142,
"estado_anterior": "EN_REVISION",
"estado_nuevo": "FIRMADO",
"user_id": 88,
"comentarios": "Aprobado"
},
"metadata": {
"sinpapel_version": "0.1.0",
"webhooks_version": "0.1.0",
"subscription_id": 5,
"api_version": "2026-04-30"
}
}
Outbound headers
POST /your-webhook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: sinpapel-webhooks/0.1.0
X-Sinpapel-Signature: t=1714492800,v1=<sha256-hex>
X-Sinpapel-Event-Id: 01HXX5K8N3...
X-Sinpapel-Event-Type: workflow.transition.completed
X-Sinpapel-Webhook-Id: 5
Custom events
from sinpapel_webhooks.emit import emit_event
emit_event(
event_type="custom.invoice.paid",
payload={"invoice_id": 142, "amount": 5000.0},
source=invoice_instance, # optional GFK
)
Event catalog (v0.2.0)
10 canonical event types. Subscribe to any of them via the events JSON list on WebhookSubscription.
| event_type | Trigger | Requires |
|---|---|---|
workflow.transition.completed |
SeguimientoWorkflow create |
sinpapel ≥ 0.1.0 |
signature.completed |
RegistroFirma create |
sinpapel ≥ 0.1.0 |
document.uploaded |
InstanciaDocumento create |
sinpapel ≥ 0.1.0 |
workflow.predicate.configured |
CondicionTransicion create/update |
sinpapel ≥ 0.4.0 |
workflow.predicate.failed |
WorkflowEngine rejects a transition due to a CondicionTransicion |
sinpapel ≥ 0.5.0 (custom Signal) |
workflow.transition.preview |
WorkflowEngine.preview_transition (opt-in via SINPAPEL_EMIT_PREVIEW_EVENTS=True) |
sinpapel ≥ 0.5.0 |
sla.configured |
SLAConfiguracion create/update |
sinpapel ≥ 0.4.0 |
sla.breached |
SLAEngine detects instance exceeded dias_maximos |
sinpapel ≥ 0.5.0 |
sla.action.executed |
SLAEngine dispatched a _accion_* handler (notificar / escalar / rechazar / alertar) |
sinpapel ≥ 0.5.0 |
workflow.metadata.captured |
Consumer-emit (call emit_event(...) from your own post_save on models using MetadatosCapturables) |
— |
Loose coupling note: events that need ≥ 0.5.0 use custom Django Signals declared in sinpapel.signals (the receivers are auto-connected at apps.ready()). Older sinpapel still works — the defensive try: from sinpapel.signals import ... falls back, so only post_save-driven events fire.
Admin REST API (v0.2.0)
Install the extra:
pip install sinpapel-webhooks[admin]
Mount the URLs (same include as the inbound endpoint):
# myproject/urls.py
urlpatterns = [
path("sinpapel/api/webhooks/", include("sinpapel_webhooks.urls")),
]
Routes (all under /sinpapel/api/webhooks/admin/):
| Verb + Path | Purpose |
|---|---|
GET /admin/subscriptions/ |
List subscriptions (paginated) |
POST /admin/subscriptions/ |
Create — response returns secret ONCE in plaintext |
GET /admin/subscriptions/{id}/ |
Retrieve — secret masked (*** + last 4 chars) |
PATCH /admin/subscriptions/{id}/ |
Update — secret is read-only here |
DELETE /admin/subscriptions/{id}/ |
Delete |
POST /admin/subscriptions/{id}/rotate-secret/ |
Rotate secret — response returns new value once |
POST /admin/subscriptions/{id}/test/ |
Send synthetic delivery via InlineBackend |
GET /admin/deliveries/ |
List deliveries — filters: ?status=, ?subscription=, ?since= |
GET /admin/deliveries/{id}/ |
Retrieve delivery |
POST /admin/deliveries/{id}/retry/ |
Re-enqueue a delivery (resets to pending) |
POST /admin/deliveries/requeue-dead-letter/ |
Body {ids: [...]} or {all: true} |
GET /admin/events/ |
List events with delivery_count |
GET /admin/events/{id}/ |
Retrieve event with embedded deliveries |
GET /admin/inbound-events/ |
List inbound dedup log — filters: ?source=, ?handler_status=, ?since= |
GET /admin/inbound-events/{id}/ |
Retrieve inbound event |
Authentication
Default permission: rest_framework.permissions.IsAdminUser. Override with the setting:
SINPAPEL_WEBHOOKS_ADMIN_PERMISSION = "myapp.permissions.IsOpsTeam"
Resolved via import_string at module import; invalid dotted paths raise ImproperlyConfigured.
Examples
# Create a subscription (note: response body is the ONLY time the full secret is visible)
curl -X POST https://api.example.com/sinpapel/api/webhooks/admin/subscriptions/ \
-H "Authorization: Token <admin-token>" \
-H "Content-Type: application/json" \
-d '{"name":"ops","url":"https://ops.example.com/hook","events":["workflow.transition.completed"],"secret":"<32-byte-hex>"}'
# Rotate the secret (server generates a new 32-byte hex value)
curl -X POST https://api.example.com/sinpapel/api/webhooks/admin/subscriptions/5/rotate-secret/ \
-H "Authorization: Token <admin-token>"
# Re-enqueue all dead-lettered deliveries
curl -X POST https://api.example.com/sinpapel/api/webhooks/admin/deliveries/requeue-dead-letter/ \
-H "Authorization: Token <admin-token>" \
-H "Content-Type: application/json" \
-d '{"all": true}'
5. Inbound
Decorator-based handler registration
# myapp/webhooks.py — auto-discovered en startup vía autodiscover_modules
from sinpapel_webhooks import webhook_receiver
@webhook_receiver(source="baz", event="payment.confirmed")
def handle_baz_payment(payload, request):
"""payload es el envelope completo (event_id, event_type, occurred_at, data, metadata)."""
pago_id = payload["data"]["pago_id"]
monto = payload["data"]["monto"]
# ... your logic
return {"acked": True, "pago_id": pago_id}
@webhook_receiver(source="moneygram", event="dispersion.completed")
def handle_moneygram_dispersion(payload, request):
"""Returns None → defaults to {"acked": True}."""
# ... your logic
Inbound URL routing
POST /sinpapel/api/webhooks/in/<source>/
<source> matches SINPAPEL_WEBHOOKS_INBOUND_SECRETS key.
Inbound status codes
| Code | Reason |
|---|---|
| 200 | Success (handler invoked) o duplicate (idempotency) |
| 400 | Missing required headers o invalid JSON body |
| 401 | HMAC signature invalid o unknown source |
| 404 | No handler registered para (source, event_type) |
| 500 | Handler raised exception |
Idempotency
InboundWebhookEvent.unique_together(source, event_id) provee atomic dedup.
Si external service POSTea mismo event_id 2 veces, segunda recibe 200 {"acked": true, "duplicate": true} sin re-invocar handler.
6. Backends — Choosing a delivery backend
| Backend | Use case | Pros | Cons |
|---|---|---|---|
inline |
Dev / smoke tests / very low volume | No infra; immediate delivery; debugging fácil | Bloquea Django request; no retry; pierde events on consumer downtime |
outbox ⭐ default |
Production default | No broker; at-least-once con retry; backpressure natural | Lag worker cycle (1-30s); requires worker process |
celery |
High volume / distributed | Distributed retry; horizontal scale; concurrency control | Requires Celery + broker (Redis/RabbitMQ) |
Configuration
# Inline (dev only)
SINPAPEL_WEBHOOKS_BACKEND = "inline"
# Outbox (default prod)
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
# Run: python manage.py sinpapel_webhooks_worker
# Celery (high volume)
SINPAPEL_WEBHOOKS_BACKEND = "celery"
# Plus your existing Celery app config (broker, etc.)
Custom backends
# my_project/webhooks_backends.py
class MyBackend:
name = "my_backend"
def enqueue(self, delivery_id): ...
def deliver_now(self, delivery_id): ...
# settings.py
SINPAPEL_WEBHOOKS_BACKEND = "my_project.webhooks_backends.MyBackend"
7. HMAC Verify (consumer-side)
Header format: X-Sinpapel-Signature: t=<unix-ts>,v1=<sha256-hex> (Stripe-compatible).
Algorithm: HMAC-SHA256(secret, f"{timestamp}.".encode() + raw_body_bytes).hexdigest().
Critical: Use raw body bytes (NOT re-serialized JSON). Some frameworks parse + re-serialize body, which changes bytes and breaks HMAC.
Test vector
payload: b'{"event":"test","data":{"x":1}}'
secret: "demo-secret"
timestamp: 1714492800
expected: t=1714492800,v1=42740cbdf2a28e4c8c81742f20936d35a6895352d2395818f04a28d4e2030e11
Python
import hmac, hashlib, time
def verify_sinpapel_signature(payload: bytes, header: str, secret: str, *, tolerance: int = 300) -> bool:
"""Returns True if HMAC valid + timestamp within tolerance."""
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance:
return False # Replay protection
expected = hmac.new(
secret.encode(),
f"{ts}.".encode() + payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
JavaScript (Node.js)
const crypto = require("crypto");
function verifySinpapelSignature(payload, header, secret, { tolerance = 300 } = {}) {
// payload: Buffer | string (raw body, NOT re-serialized JSON)
// header: "t=<ts>,v1=<hex>"
const parts = Object.fromEntries(
header.split(",").map(p => p.split("=", 2))
);
const ts = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.`)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
}
Ruby
require "openssl"
def verify_sinpapel_signature(payload, header, secret, tolerance: 300)
parts = Hash[header.split(",").map { |p| p.split("=", 2) }]
ts = parts["t"].to_i
return false if (Time.now.to_i - ts).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{payload}")
Rack::Utils.secure_compare(expected, parts["v1"])
end
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func VerifySinpapelSignature(payload []byte, header, secret string, tolerance int64) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-ts) > tolerance {
return false
}
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(strconv.FormatInt(ts, 10) + "."))
h.Write(payload)
expected := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func abs(x int64) int64 {
if x < 0 { return -x }
return x
}
8. Workers — Deployment
Long-running daemon (production)
python manage.py sinpapel_webhooks_worker
Cron / one-shot
python manage.py sinpapel_webhooks_worker --once
Custom batch size + poll interval
python manage.py sinpapel_webhooks_worker --batch-size 100 --poll-interval 10
systemd unit example
# /etc/systemd/system/sinpapel-webhooks-worker.service
[Unit]
Description=sinpapel-webhooks outbox worker
After=postgresql.service
[Service]
Type=simple
User=django
WorkingDirectory=/opt/myproject
Environment=DJANGO_SETTINGS_MODULE=myproject.settings
ExecStart=/opt/myproject/venv/bin/python manage.py sinpapel_webhooks_worker
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Operational commands
# Re-queue dead letter deliveries
python manage.py sinpapel_webhooks_requeue_dead_letter --all
python manage.py sinpapel_webhooks_requeue_dead_letter --id 142
# Test subscription URL + secret
python manage.py sinpapel_webhooks_test_subscription 5
9. Migrations
python manage.py migrate sinpapel_webhooks
Creates 4 tables:
sinpapel_webhooks_webhooksubscription(+ history table for audit)sinpapel_webhooks_webhookeventsinpapel_webhooks_webhookdeliverysinpapel_webhooks_inboundwebhookevent
3-package coexistence: sinpapel + sinpapel_drf + sinpapel_webhooks correr en mismo Django process sin migration conflicts (verified vía 1200+ test suite).
PostgreSQL recommended para outbox backend multi-worker (SELECT FOR UPDATE SKIP LOCKED). SQLite fallback es single-worker only.
10. Troubleshooting
Celery test config NOT propagated via Django settings
# WRONG — Django override_settings NO propaga a Celery config
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
def test_celery_backend(): ...
# CORRECT — set Celery config directly
@pytest.fixture(autouse=True)
def _eager_celery():
from celery import current_app
current_app.conf.task_always_eager = True
current_app.conf.task_eager_propagates = True
yield
HMAC byte-exact requirement
# WRONG — request.POST parses form-encoded; modifies bytes
body = request.POST.get("payload")
# WRONG — json.loads + re-serialize cambia bytes
body = json.dumps(json.loads(request.body))
# CORRECT — request.body returns raw bytes
body = request.body # bytes
autodiscover_modules edge cases
- Apps SIN
webhooks.py→ silently skipped (Django stdlib documented behavior) - Apps con
webhooks.pysyntax error → ImportError raised at startup (loud failure) webhooks.pyimports modules requiring DB → ensure migrations applied before test runner
bulk_create NO dispara signals
# WRONG — bulk_create bypasses post_save signals
SeguimientoWorkflow.objects.bulk_create([...]) # NO webhooks emitted
# CORRECT — use .save() iterativo if signals required
for s in seguimientos:
s.save() # signals fire normally
Outbox queue grows unbounded
If consumer URL is permanently down, deliveries accumulate as pending → failed → retry → eventually dead_letter. Monitor:
WebhookDelivery.objects.filter(status="dead_letter").count()
WebhookDelivery.objects.filter(status="failed").count()
Operational response: python manage.py sinpapel_webhooks_requeue_dead_letter after fixing consumer.
11. Reference
- Architecture:
work/epics/e14-sinpapel-webhooks/design.md - ADR-013 (delivery port pluggable):
dev/decisions/adr-013-webhook-delivery-pluggable-backend.md - ADR-014 (HMAC signing scheme):
dev/decisions/adr-014-webhook-signing-hmac-sha256.md
Public API
from sinpapel_webhooks import webhook_receiver, __version__
from sinpapel_webhooks.signing import compute_signature, verify_signature
from sinpapel_webhooks.emit import emit_event
from sinpapel_webhooks.delivery.ports import WebhookDeliveryBackend, DeliveryResult
from sinpapel_webhooks.exceptions import (
SinpapelWebhooksError,
WebhookSignatureError,
WebhookDeliveryError,
WebhookReceiverNotFound,
)
Models
from sinpapel_webhooks.models import (
WebhookSubscription, # outbound config
WebhookEvent, # canonical event log
WebhookDelivery, # delivery attempt log
InboundWebhookEvent, # idempotency dedup
)
12. Versioning
- v0.2.0 (2026-05-16) — Event catalog expansion (7 new event types) + Admin REST API (Subscriptions CRUD + Deliveries read/retry + Events/InboundEvents read). Requires
sinpapel >=0.5.0for the 4 signal-driven events; older sinpapel still loads (defensive import) with 2post_save-driven events functional. - v0.1.0 (2026-05-01) — Initial release. Epic E14 close.
- Lockstep:
sinpapel_webhooks 0.2.xrequiressinpapel >=0.5.0,<0.6for full feature set (degrades gracefully on older sinpapel). - Future: v0.3 may add rate limiting, drf-spectacular OpenAPI schema, multi-tenancy, mTLS, Kafka backend.
See CHANGELOG.md for full change history.
13. FAQ
¿Por qué outbox es default en lugar de inline?
Outbox no requiere broker (DB-backed queue) y provee at-least-once delivery con retry automático. Inline pierde events si consumer URL está down. Outbox = production-ready out-of-box.
¿Cómo migro de inline a outbox?
Single setting change + run worker:
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
python manage.py sinpapel_webhooks_worker
¿Cómo testeo Celery backend sin Redis broker?
from celery import current_app
current_app.conf.task_always_eager = True # NOT via Django settings
current_app.conf.task_eager_propagates = True
¿Por qué no DRF en inbound view?
Inbound es server-to-server JSON only — DRF parser/renderer overhead innecesario. Plain Django view + JsonResponse sufficient. Mantiene loose coupling con sinpapel_drf.
¿Cómo agregar custom event types?
emit_event(event_type="custom.X", payload={...}, source=instance) desde tu código. Subscriptions matching events__contains="custom.X" reciben.
¿Sinpapel-webhooks soporta WebSocket / SSE?
No. Para push real-time usar Channels separado. Webhooks son HTTP POST callbacks event-driven.
¿Hay admin REST endpoints (subscription CRUD)?
Sí, desde v0.2.0. Install con pip install sinpapel-webhooks[admin] y monta el include — automáticamente expone /sinpapel/api/webhooks/admin/subscriptions/ (CRUD + rotate-secret + test), /admin/deliveries/ (read + retry + requeue-dead-letter), /admin/events/, /admin/inbound-events/. Default permission IsAdminUser; override via SINPAPEL_WEBHOOKS_ADMIN_PERMISSION setting.
¿Cómo rotar secrets?
Outbound (v0.2.0+): POST /sinpapel/api/webhooks/admin/subscriptions/{id}/rotate-secret/ — server genera 32-byte hex nuevo y la response devuelve el valor en plaintext (única vez). El campo secret en GET/PATCH viene enmascarado (*** + last 4 chars).
Inbound: update SINPAPEL_WEBHOOKS_INBOUND_SECRETS dict y restart Django process. Dual-secret overlap window queda en backlog.
License: GPL-3.0-or-later — see LICENSE. Source: https://github.com/aprendomx/sinpapel-webhooks Issues: https://github.com/aprendomx/sinpapel-webhooks/issues
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sinpapel_webhooks-0.2.1.post1.tar.gz.
File metadata
- Download URL: sinpapel_webhooks-0.2.1.post1.tar.gz
- Upload date:
- Size: 59.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7c3c0a113ade277a0c194942a5895c81e5cf66d4e2644006123d5975ed3a48de
|
|
| MD5 |
73080124d5b09a1157ac2cf8e6cfd877
|
|
| BLAKE2b-256 |
7e5d1b94c5c012bc47e3efa75387e5e7a93588b985fc1b444950d1eea419333e
|
File details
Details for the file sinpapel_webhooks-0.2.1.post1-py3-none-any.whl.
File metadata
- Download URL: sinpapel_webhooks-0.2.1.post1-py3-none-any.whl
- Upload date:
- Size: 67.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
119ef9f780bb5ccefc1d0568ff76d9c14ce3dad608ac08dccf40039008bd6b33
|
|
| MD5 |
9ea0ef3221a60eb139ece4a396105a26
|
|
| BLAKE2b-256 |
90c29cb5a9da0448e36e4257c87b35bee5b3990320e8fc0ed42a3a6b4408152f
|