Skip to main content

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 label
  • url: target POST URL
  • events: JSON list of event types subscribed
  • secret: HMAC-SHA256 secret (32-byte hex recommended)
  • active: enable/disable
  • created_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_webhookevent
  • sinpapel_webhooks_webhookdelivery
  • sinpapel_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.py syntax error → ImportError raised at startup (loud failure)
  • webhooks.py imports 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

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.0 for the 4 signal-driven events; older sinpapel still loads (defensive import) with 2 post_save-driven events functional.
  • v0.1.0 (2026-05-01) — Initial release. Epic E14 close.
  • Lockstep: sinpapel_webhooks 0.2.x requires sinpapel >=0.5.0,<0.6 for 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

sinpapel_webhooks-0.2.1.tar.gz (59.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sinpapel_webhooks-0.2.1-py3-none-any.whl (67.3 kB view details)

Uploaded Python 3

File details

Details for the file sinpapel_webhooks-0.2.1.tar.gz.

File metadata

  • Download URL: sinpapel_webhooks-0.2.1.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

Hashes for sinpapel_webhooks-0.2.1.tar.gz
Algorithm Hash digest
SHA256 0e47ce05e7d5705281834e9a554f13da4525e80dbe8f18ae204dda184b8bca5f
MD5 0944c97d66506eada0685745bcf4afa6
BLAKE2b-256 428d4b9909d247a6b6895028fee9887cbef6deb91b94a7d888f92f1f0b460c50

See more details on using hashes here.

File details

Details for the file sinpapel_webhooks-0.2.1-py3-none-any.whl.

File metadata

File hashes

Hashes for sinpapel_webhooks-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 7714ba0803269885b3f5945c74eaecbe628c3af4a1b16b36b1f176604b9536cd
MD5 e1675efdd0aef019b6169d9945496b18
BLAKE2b-256 5536106fbe6de94a5d8dc56b49ecec748d349da8fb79d0eacc885e3355afe3b7

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page