Metadata-Version: 2.4
Name: sinpapel-webhooks
Version: 0.2.1.post1
Summary: Event-driven HTTP communication para sinpapel — outbound + inbound webhooks (HMAC signing, pluggable delivery backends, idempotency)
Author-email: Julio Adrián <jadrian.s@gmail.com>
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/aprendomx/sinpapel-webhooks
Project-URL: Issues, https://github.com/aprendomx/sinpapel-webhooks/issues
Keywords: django,webhooks,events,hmac,sinpapel,workflow,outbound,inbound
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: sinpapel>=0.7.0
Requires-Dist: django-simple-history>=3.4
Requires-Dist: requests>=2.31
Provides-Extra: dev
Requires-Dist: pytest>=9.0; extra == "dev"
Requires-Dist: pytest-django>=4.12; extra == "dev"
Requires-Dist: responses>=0.25; extra == "dev"
Provides-Extra: celery
Requires-Dist: celery>=5.3; extra == "celery"
Provides-Extra: drf
Requires-Dist: sinpapel-drf>=0.4.1; extra == "drf"
Provides-Extra: admin
Requires-Dist: djangorestframework>=3.14; extra == "admin"
Dynamic: license-file

# sinpapel-webhooks

> **Event-driven HTTP communication for [sinpapel](https://github.com/aprendomx/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](https://github.com/aprendomx/sinpapel-webhooks/blob/main/CHANGELOG.md) · [README en español](https://github.com/aprendomx/sinpapel-webhooks/blob/main/README.es.md).

---

## 1. Installation

```bash
# 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

```python
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

```python
from django.urls import include, path

urlpatterns = [
    # ... existing patterns ...
    path("sinpapel/api/webhooks/", include("sinpapel_webhooks.urls")),
]
```

### Step 3 — myapp/webhooks.py (auto-discovered)

```python
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

```bash
python manage.py migrate sinpapel_webhooks
python manage.py sinpapel_webhooks_worker  # daemon mode
```

### Step 5 — Create outbound subscription (Django admin or shell)

```python
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

```json
{
  "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

```python
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:

```bash
pip install sinpapel-webhooks[admin]
```

Mount the URLs (same `include` as the inbound endpoint):

```python
# 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:

```python
SINPAPEL_WEBHOOKS_ADMIN_PERMISSION = "myapp.permissions.IsOpsTeam"
```

Resolved via `import_string` at module import; invalid dotted paths raise `ImproperlyConfigured`.

### Examples

```bash
# 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

```python
# 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

```python
# 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

```python
# 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

```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)

```javascript
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

```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

```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)

```bash
python manage.py sinpapel_webhooks_worker
```

### Cron / one-shot

```bash
python manage.py sinpapel_webhooks_worker --once
```

### Custom batch size + poll interval

```bash
python manage.py sinpapel_webhooks_worker --batch-size 100 --poll-interval 10
```

### systemd unit example

```ini
# /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

```bash
# 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

```bash
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

```python
# 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

```python
# 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

```python
# 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:

```python
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

```python
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

```python
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](https://github.com/aprendomx/sinpapel-webhooks/blob/main/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:
```python
SINPAPEL_WEBHOOKS_BACKEND = "outbox"
```
```bash
python manage.py sinpapel_webhooks_worker
```

### ¿Cómo testeo Celery backend sin Redis broker?

```python
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](https://github.com/aprendomx/sinpapel-webhooks/blob/main/LICENSE).
**Source:** https://github.com/aprendomx/sinpapel-webhooks
**Issues:** https://github.com/aprendomx/sinpapel-webhooks/issues
