# EventForge GitHub guide

Test event: issues (action opened)

[Quickstart](https://event-forge.app/docs.md) | [API reference](https://event-forge.app/docs/api.md)

## Manual boundary and prerequisites

A person creates and verifies an EventForge test account, creates a Full access API key and a separate Read diagnosis key without write permissions in Settings, and loads credentials into local environment variables. A person also controls the provider test account and its webhook secret. Never paste secrets into chat, URLs, source, logs or fixture files. The agent can then configure the project and rule, inspect events and invoke Replay. Run shell snippets in Bash. Use Go 1.26.10, curl, jq, uuidgen, an available loopback port 3000 and an existing free public HTTPS receiver or tunnel to 127.0.0.1:3000; no paid infrastructure is needed. For a real provider run, EF_BASE_URL and INBOX_URL must also be publicly reachable HTTPS URLs; the isolated localhost environment is for synthetic tests. Use only synthetic test data.

An API key cannot create/manage API keys or retrieve/rotate a webhook secret: those operations require a signed-in browser session. Project creation returns its outbound webhookSecret once; save it locally without printing the project response. Use a dedicated empty project for this guide. Keep a local start/end time and record friction; no elapsed-time target has been measured.

## Create the test project

`POST /api/v1/projects`

Set EVENTFORGE_FULL_KEY and EVENTFORGE_READ_KEY locally using your secret manager or a non-echoing prompt. The first is used only for writes; inspection uses the second. Do not enable shell tracing. The command keeps the creation response in process memory and does not print its secret.

```bash
export EF_BASE_URL="https://event-forge.app"
export PROVIDER="github"
eventforge_demo_create_project() {
  local response project_id public_id project_secret
  if [[ -n "${PROJECT_ID:-}" || -z "${EVENTFORGE_FULL_KEY:-}" ]]; then
    echo 'Keep the recorded project or clean it up first; a full-access key is required' >&2; return 1
  fi
  if ! response=$(curl --fail-with-body -sS "$EF_BASE_URL/api/v1/projects" \
    -H "Authorization: Bearer $EVENTFORGE_FULL_KEY" -H 'Content-Type: application/json' \
    -d '{"name":"Provider guide test"}'); then
    printf '%s' "$response" | jq '{error,message}' >&2
    echo 'Project creation failed; inspect Projects for an uncertain network result before retrying' >&2
    return 1
  fi
  if ! project_id=$(printf '%s' "$response" | jq -er '.id | select(type == "string" and length > 0)'); then
    echo 'Unexpected project response; inspect Projects before retrying' >&2; return 1
  fi
  export PROJECT_ID="$project_id"
  if ! public_id=$(printf '%s' "$response" | jq -er '.publicId | select(type == "string" and startswith("proj_"))') ||
     ! project_secret=$(printf '%s' "$response" | jq -er '.webhookSecret | select(type == "string" and startswith("whsec_"))'); then
    echo 'Incomplete project response; PROJECT_ID retained for cleanup; stop here' >&2; return 1
  fi
  export PUBLIC_ID="$public_id" EVENTFORGE_WEBHOOK_SECRET="$project_secret"
  export INBOX_URL="$EF_BASE_URL/inbox/$PUBLIC_ID/$PROVIDER"
}
if eventforge_demo_create_project; then
  echo 'Project configured; signing secret loaded locally'
else
  echo 'Stop here. Fix project creation before continuing.' >&2
fi
unset -f eventforge_demo_create_project
```

## Configure the provider

Configure the subscription/listener now. Run the test-event action below only at the Receive and diagnose step, after the receiver and rule exist. Use a disposable repository you control, with Issues enabled and permission to manage its webhooks. In repository Settings → Webhooks → Add webhook, set Payload URL to INBOX_URL, Content type to application/json, Secret to the local PROVIDER_WEBHOOK_SECRET value, and enable SSL verification. Select individual events → Issues, and leave the webhook active. Use a random dedicated secret; do not reuse the EventForge project secret. GitHub sends an initial ping during this step. Because this dedicated project has no rule yet, it is normally stored as no_match with no delivery. If a delayed or redelivered ping reaches the receiver after a rule exists, the receiver refuses it because it is not an opened issue.

## Run the complete receiver

Copy all of the Go source below into main.go in a new empty local directory; no private repository access is needed. This reuses the Quickstart's Standard Webhooks NewWebhook/Verify calls with a pinned SDK. Pin dependencies using these commands. PROVIDER_WEBHOOK_SECRET must contain the independent provider secret loaded locally. Run one provider per process. The startup check below shows receiver.log if the process exits (for example, missing secrets or occupied port 3000). Stop and fix that error before creating the route or sending an event. Keep the process running through retry and Replay so its in-memory identity map survives. A restart clears all deduplication state.

```bash
go mod init provider-receiver
go get github.com/standard-webhooks/standard-webhooks/libraries@v0.0.1
go get github.com/stripe/stripe-go/v84@v84.1.0
# Explicit export also covers secrets loaded with a local read prompt.
export PROVIDER PROVIDER_WEBHOOK_SECRET EVENTFORGE_WEBHOOK_SECRET
export FAIL_FILE="$PWD/receiver.fail"
if go build -o receiver . && touch "$FAIL_FILE"; then
  ./receiver >receiver.log 2>&1 &
  export RECEIVER_PID=$!
  sleep 1
  if kill -0 "$RECEIVER_PID" 2>/dev/null; then
    echo 'Receiver process is running; continue with the route'
  else
    unset RECEIVER_PID
    cat receiver.log >&2
    echo 'Stop here: check exported secrets and whether port 3000 is available' >&2
  fi
else
  echo 'Stop here: fix the receiver build or failure-file error before continuing' >&2
fi
```

```go
// Run this standalone example using the module setup in the public provider guide.
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
	"github.com/stripe/stripe-go/v84/webhook"
)

type receiver struct {
	envelope                   *standardwebhooks.Webhook
	provider, secret, failFile string
	mu                         sync.Mutex
	seen                       map[string]bool
	effects                    int
}

func newReceiver(provider, envelopeSecret, providerSecret, failFile string) (*receiver, error) {
	if provider != "github" && provider != "stripe" && provider != "shopify" {
		return nil, errors.New("PROVIDER must be github, stripe or shopify")
	}
	if providerSecret == "" || !strings.HasPrefix(envelopeSecret, "whsec_") {
		return nil, errors.New("set both independent webhook secrets")
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(envelopeSecret, "whsec_"))
	if err != nil || len(key) < 32 {
		return nil, errors.New("invalid EventForge secret")
	}
	wh, err := standardwebhooks.NewWebhook(envelopeSecret)
	if err != nil {
		return nil, errors.New("invalid EventForge secret")
	}
	return &receiver{envelope: wh, provider: provider, secret: providerSecret, failFile: failFile, seen: make(map[string]bool)}, nil
}

func (s *receiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost || r.URL.Path != "/webhooks" {
		http.NotFound(w, r)
		return
	}
	// Verify these exact bytes, never a re-encoded JSON object.
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 5<<20))
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}
	// Same Standard Webhooks Verify call as the Quickstart; both age and HMAC apply.
	if err := s.envelope.Verify(body, r.Header); err != nil {
		http.Error(w, "invalid EventForge signature or timestamp", http.StatusBadRequest)
		return
	}
	identity, err := s.identity(body, r.Header)
	if err != nil {
		// Do not log SDK errors, headers, payloads or secrets.
		http.Error(w, "invalid provider signature, timestamp or demo event", http.StatusBadRequest)
		return
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.failFile != "" {
		_, err := os.Stat(s.failFile)
		if err == nil || !errors.Is(err, os.ErrNotExist) {
			http.Error(w, "demo receiver unavailable", http.StatusServiceUnavailable)
			return
		}
	}
	if s.seen[identity] {
		log.Printf("provider=%s duplicate effects=%d", s.provider, s.effects)
		w.WriteHeader(http.StatusNoContent)
		return
	}
	// Bounded, non-durable process memory. Production needs one durable atomic
	// transaction for the idempotency record AND the business effect.
	if len(s.seen) >= 10000 {
		http.Error(w, "demo identity capacity", http.StatusServiceUnavailable)
		return
	}
	s.seen[identity] = true
	s.effects++ // The only sample effect: count this authenticated observation.
	log.Printf("provider=%s applied effects=%d", s.provider, s.effects)
	w.WriteHeader(http.StatusNoContent)
}

func (s *receiver) identity(body []byte, h http.Header) (string, error) {
	if s.provider == "stripe" {
		// v84.1.0: default 300-second age check and API release-train check.
		// Never use IgnoreTolerance, widen tolerance, or substitute envelope freshness.
		event, err := webhook.ConstructEvent(body, h.Get("Stripe-Signature"), s.secret)
		if err != nil {
			return "", err
		}
		if event.Type != "payment_intent.succeeded" || event.ID == "" || event.Livemode {
			return "", errors.New("expected sandbox payment_intent.succeeded")
		}
		return "stripe:" + event.ID, nil
	}
	mac := hmac.New(sha256.New, []byte(s.secret))
	_, _ = mac.Write(body)
	var supplied []byte
	var err error
	if s.provider == "github" {
		signature, ok := strings.CutPrefix(h.Get("X-Hub-Signature-256"), "sha256=")
		if !ok {
			return "", errors.New("missing GitHub signature")
		}
		supplied, err = hex.DecodeString(signature)
	} else {
		supplied, err = base64.StdEncoding.DecodeString(h.Get("X-Shopify-Hmac-Sha256"))
	}
	if err != nil || !hmac.Equal(supplied, mac.Sum(nil)) {
		return "", errors.New("invalid provider HMAC")
	}
	if s.provider == "github" {
		var event struct {
			Action     string `json:"action"`
			Repository struct {
				ID int64 `json:"id"`
			} `json:"repository"`
			Issue struct {
				ID     int64 `json:"id"`
				Number int64 `json:"number"`
			} `json:"issue"`
		}
		if err := json.Unmarshal(body, &event); err != nil {
			return "", err
		}
		if event.Action != "opened" || event.Repository.ID <= 0 || event.Issue.ID <= 0 || event.Issue.Number <= 0 {
			return "", errors.New("expected issues opened body")
		}
		return fmt.Sprintf("github:%d:%d:opened", event.Repository.ID, event.Issue.ID), nil
	}
	var order struct {
		ID        int64  `json:"id"`
		CreatedAt string `json:"created_at"`
	}
	if err := json.Unmarshal(body, &order); err != nil {
		return "", err
	}
	if order.ID <= 0 || order.CreatedAt == "" {
		return "", errors.New("expected order body")
	}
	// This counts an order observation, not proof of an orders/create topic:
	// Shopify's HMAC covers the body, not X-Shopify-Topic or shop-domain headers.
	return fmt.Sprintf("shopify:%d:%s", order.ID, order.CreatedAt), nil
}

func main() {
	s, err := newReceiver(os.Getenv("PROVIDER"), os.Getenv("EVENTFORGE_WEBHOOK_SECRET"), os.Getenv("PROVIDER_WEBHOOK_SECRET"), os.Getenv("FAIL_FILE"))
	if err != nil {
		log.Fatal(err)
	}
	server := &http.Server{Addr: "127.0.0.1:3000", Handler: s, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second}
	log.Print("demo receiver listening on 127.0.0.1:3000/webhooks")
	log.Fatal(server.ListenAndServe())
}

```

## Route to the receiver

`POST /api/v1/projects/{projectId}/rules`

Set RECEIVER_URL to your existing HTTPS tunnel's full /webhooks URL. The path_prefix value is /github, /stripe or /shopify: rules compare the suffix after /inbox/PUBLIC_ID, even though event.path stores the full path. A path is routing, not authentication. Never add an all rule to a production receiver assuming another path rule protects it. The inbox does not verify the provider: a sender can submit unsigned or captured requests to any path. This receiver checks the fixed provider secret regardless of route. It trusts the EventForge project secret and dedicated provider test secret; it makes only a harmless observation. Source enforcement and authenticated intake evidence are not provided by this guide.

```bash
# Load RECEIVER_URL locally, for example your existing tunnel's https://.../webhooks.
eventforge_demo_create_rule() {
  local request response rule_id
  if [[ -n "${RULE_ID:-}" || -z "${PROJECT_ID:-}" || -z "${EVENTFORGE_FULL_KEY:-}" || -z "${PROVIDER:-}" || "${RECEIVER_URL:-}" != https://* ]]; then
    echo 'Keep the recorded rule or clean it up first; load the project, key and HTTPS RECEIVER_URL' >&2; return 1
  fi
  if ! request=$(jq -nc --arg path "/$PROVIDER" --arg target "$RECEIVER_URL" \
    '{name:"Provider test",conditionType:"path_prefix",conditionValue:$path,targetUrl:$target}'); then
    echo 'Could not build the rule request' >&2; return 1
  fi
  if ! response=$(curl --fail-with-body -sS "$EF_BASE_URL/api/v1/projects/$PROJECT_ID/rules" \
    -H "Authorization: Bearer $EVENTFORGE_FULL_KEY" -H 'Content-Type: application/json' -d "$request"); then
    printf '%s' "$response" | jq '{error,message}' >&2
    echo 'Rule creation failed; inspect project rules for an uncertain network result before retrying' >&2; return 1
  fi
  if ! rule_id=$(printf '%s' "$response" | jq -er '.id | select(type == "string" and length > 0)'); then
    echo 'Unexpected rule response; inspect project rules before retrying' >&2; return 1
  fi
  export RULE_ID="$rule_id"
}
if eventforge_demo_create_rule; then
  echo 'Rule configured'
else
  echo 'Stop here. Fix rule creation before continuing.' >&2
fi
unset -f eventforge_demo_create_rule
```

## Receive and diagnose the induced failure

`GET /api/v1/projects/{projectId}/events`

Now trigger the named provider event using the preceding provider instructions. FAIL_FILE deliberately causes 503 only after both signatures and the demo body pass. Provider acceptance of the inbox is separate from receiver success. List candidate IDs with the read-only key, then set EVENT_ID to a candidate and inspect its body with the detail command below. Select the named test payload, not just the latest event (GitHub also sends ping and Stripe can emit other event types). Keep diagnostics local because payloads may contain provider data.

```bash
# In this Bash shell, use GitHub CLI authenticated to your test repository:
gh --version
export TEST_REPOSITORY="your-account/your-disposable-test-repository"
gh issue create --repo "$TEST_REPOSITORY" --title 'EventForge receiver demo' --body 'Disposable webhook test'
curl --fail-with-body -sS "$EF_BASE_URL/api/v1/projects/$PROJECT_ID/events?limit=10" \
  -H "Authorization: Bearer $EVENTFORGE_READ_KEY" | jq '.data[] | {id,path,deliveryStatus,deliverySummary}'
# Set EVENT_ID locally to the selected test event's id.
```

`GET /api/v1/events/{eventId}`

Inspect deliveries: status, messageId, attemptNumber, responseStatus, responseBody, lastError and nextAttemptAt. Expect an attempt with responseStatus=503, plus a pending retry with nextAttemptAt once the worker commits it. A network error or tunnel 502 can mean the receiver never started: check receiver.log and the startup check, exported secrets, port 3000 and tunnel forwarding. A 400 about the provider means check the separate provider secret and age; a 400 about EventForge means check the project secret/clock. Missing or tampered signatures apply no effect. No delivery can mean no matching enabled rule; check the stored path, its suffix used by the rule and the target URL. Redirects are not followed. A private/local target can be blocked by SSRF policy; use the HTTPS tunnel for hosted EventForge.

```bash
curl --fail-with-body -sS "$EF_BASE_URL/api/v1/events/$EVENT_ID" \
  -H "Authorization: Bearer $EVENTFORGE_READ_KEY" | jq '{id,path,body,deliveries}'
```

## Fix the receiver and observe retry

In the receiver directory, remove only receiver.fail using the command below; keep the receiver process alive. Wait for the pending nextAttemptAt and poll the same event detail until a delivery is success. EventForge retries network errors, timeouts, 408, 425, 429 and 5xx; other 4xx are terminal. Automatic attempts keep webhook-id/messageId and use a fresh EventForge timestamp, while the stored provider signature stays unchanged. Attempts 2–7 target about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours from the first attempt with jitter. For Stripe, finish promptly: once the original signature is older than five minutes the expected result becomes 400 with no effect. A provider retry is a new intake; EventForge retry is a new attempt of the stored intake.

```bash
rm receiver.fail
```

## Authorize Replay and verify one effect

`POST /api/v1/events/{eventId}/replay`

After the successful retry, deliberately Replay that event. Expect 202 with message and deliveriesCreated; a single matching rule gives 1. Poll event detail for a new messageId and success, and check the receiver logs: a fresh one-event process shows applied effects=1 followed by duplicate effects=1. For a repeated demonstration, compare against the effect count recorded before the new event: one increment, then duplicates with no further increment. Stripe must Replay inside the original five-minute window; an expired original is an expected refusal, never recovery. Repeating a Replay HTTP request is distinct from redelivering a consumer effect. Keep REPLAY_KEY for retries of this one API action; explicitly replace it with a new UUID only for a separate deliberate action. Copying the block again keeps the existing key. Sending the header is safe on older versions but does not establish that their server implements idempotency.

```bash
# Copying this block again reuses the same key for retries of this action.
if [[ -z "${REPLAY_KEY:-}" ]]; then
  REPLAY_KEY=$(uuidgen) || unset REPLAY_KEY
fi
if [[ -n "${REPLAY_KEY:-}" ]]; then
  export REPLAY_KEY
  if curl --fail-with-body -sS -X POST "$EF_BASE_URL/api/v1/events/$EVENT_ID/replay" \
    -H "Authorization: Bearer $EVENTFORGE_FULL_KEY" -H "Idempotency-Key: $REPLAY_KEY"; then
    echo 'Replay accepted; poll GET event detail before inspecting the receiver log'
  else
    echo 'Replay refused or uncertain; retain REPLAY_KEY, inspect the response and event before retrying' >&2
  fi
else
  echo 'Stop here: create a Replay UUID before sending the request' >&2
fi
```

## Inspect completed Replay

Replay is asynchronous. Repeat the earlier GET event detail command using EVENTFORGE_READ_KEY until a new messageId, different from the successful automatic retry's messageId, has status=success. Only then run the log command below. For a fresh one-event process, expect duplicate effects=1. When repeating the demonstration, expect duplicates at the new event's successful-retry total (the recorded baseline plus one), with no further increment from Replay. If the chain is pending, keep polling its nextAttemptAt; if it failed, inspect responseStatus/lastError first. An expired original Stripe signature is an expected 400 refusal. Keep the existing REPLAY_KEY while investigating this action.

```bash
# Run only after GET event detail confirms the new Replay chain succeeded.
tail -n 10 receiver.log
```

## Limits and release boundary

Check the deployed build/tag and API reference before relying on Replay safety. The #42 candidate contract retains a successful Idempotency-Key action for 24 hours, creates new message IDs on a deliberate Replay, charges one current-month quota unit only when at least one delivery is created, and uses a shared per-user rate bucket. A merge is not deployment evidence; these candidate semantics require a release containing #42. Do not infer live acceptance from this guide. Without server idempotency support, retrying an ambiguous POST may create extra chains.

A Read key without additional permissions cannot Replay or mutate resources (403); missing/invalid credentials give 401. Check error and message in JSON errors: plan/quota refusal, 429 with Retry-After, 409 idempotency_conflict or replay_in_progress on the supporting release, and 404 for a missing/expired event. Honor Retry-After, retain an uncertain key, and inspect the event before a new action. Retention and monthly quotas follow the effective plan; a productive Replay counts against quota and replay does not restore an expired event. See the current API reference and pricing rather than assuming unlimited replay.

## Identity, ordering and the three freshness properties

The sample counts one opened issue using repository.id + issue.id + action from the signed body. X-GitHub-Delivery and X-GitHub-Event are unsigned metadata; changing either cannot create another effect. The selected event is issues/opened; the body must contain action=opened and an issue and repository identity. This is a test observation, not a generic authorization to modify the repository.

GitHub's body HMAC authenticates neither X-GitHub-Delivery nor a universal five-minute ingress window. A captured valid opened-issue body can be resubmitted to the unverified inbox. An already-seen identity is suppressed in this process; a first-seen old signed body can count as a new observation. Never treat this counter as proof of a fresh GitHub action. For consequential processing, validate current authoritative repository state and permissions.

Provider authorship of a body, freshness of original ingress and freshness of the current EventForge delivery are separate properties. Standard Webhooks binds webhook-id, webhook-timestamp and the body; it does not authenticate arbitrary provider metadata or X-EventForge-Event-ID/Delivery-ID/Attempt. The latter are diagnostic only. Provider signatures are forwarded as X-Hub-Signature-256, Stripe-Signature and X-Shopify-Hmac-Sha256; only original Standard Webhooks headers are renamed X-Original-Webhook-Id/Timestamp/Signature. The first header value is stored. See Quickstart for the shared filtering policy, including credential and proxy headers; the receiver needs none of those headers.

GitHub does not automatically retry a failed webhook delivery. In the webhook's Recent deliveries, select the opened-issue delivery and Redeliver to demonstrate a provider redelivery. This creates another inbox event; the receiver suppresses the same signed issue identity. GitHub considers an accepted inbox request successful even when the later receiver delivery fails.

The counter is process-local and non-durable, bounded to 10,000 identities; at capacity it refuses new effects. Concurrent repeats are serialized. Production needs an atomic durable idempotency record committed with the business effect, scoped to the authenticated provider/account and application action. Events can arrive out of order. This counter accepts independent observations in either order and never overwrites business state with an old payload; a real integration must reconcile current authoritative state or enforce a domain version/sequence. It does not offer generic exactly-once processing. Future verified-ingress support would need authenticated evidence bound to this event/source; a sender flag or unsigned metadata is insufficient.

## Record acceptance and clean up

`DELETE /api/v1/rules/{ruleId}`

Record EventForge build/tag, Go version, pinned Standard Webhooks/Stripe SDK versions, provider CLI/API version, named event, local start/end times, redacted intake ID, failed attempt status, retry, Replay message IDs and final effect count. For Stripe also record original timestamp unchanged and fresh envelope on a delivery delayed over five minutes, a next-day Replay and first-seen stale reinjection: each must refuse with no additional effects. Run such cases only with synthetic sandbox data. Local signatures/fixtures and isolated app runs are not real provider acceptance. Owner-controlled GitHub, Stripe and Shopify sandbox runs remain pending until recorded; this page makes no claim that they have occurred.

Delete/disable the test provider webhook or subscription, close the disposable GitHub issue or remove Shopify test orders as appropriate, then delete the dedicated EventForge rule/project. Before running cleanup, stop the receiver using job control in the same interactive Bash shell: run jobs -l, identify the ./receiver job from this guide, bring that job to the foreground with fg %JOB_NUMBER (replace JOB_NUMBER with its listed job number), and press Ctrl-C. An already completed job needs no signal. Stop the tunnel and remove receiver.fail if present, revoke the test keys in browser Settings and unset local secrets. Deleting the project removes its test events/deliveries; inspect success before cleanup. The guarded commands stop on a failed DELETE and retain IDs/credentials for retry. Successful stages clear their IDs so a later retry skips them. If a request had an ambiguous network result and retry returns 404, first verify deletion in the correct account console; only then clear that completed resource ID to resume.

```bash
# First stop the receiver through the interactive shell job control described above.
eventforge_demo_cleanup() {
  if [[ ( -n "${RULE_ID:-}" || -n "${PROJECT_ID:-}" ) && ( -z "${EVENTFORGE_FULL_KEY:-}" || -z "${EF_BASE_URL:-}" ) ]]; then
    echo 'Restore EF_BASE_URL and the full-access test key before retrying cleanup' >&2
    return 1
  fi
  if [[ -n "${RULE_ID:-}" ]]; then
    if ! curl --fail-with-body -sS -X DELETE "$EF_BASE_URL/api/v1/rules/$RULE_ID" \
      -H "Authorization: Bearer $EVENTFORGE_FULL_KEY"; then
      echo 'Rule deletion failed; project and credentials are retained' >&2
      return 1
    fi
    unset RULE_ID
  fi
  if [[ -n "${PROJECT_ID:-}" ]]; then
    if ! curl --fail-with-body -sS -X DELETE "$EF_BASE_URL/api/v1/projects/$PROJECT_ID" \
      -H "Authorization: Bearer $EVENTFORGE_FULL_KEY"; then
      echo 'Project deletion failed; project ID and credentials are retained' >&2
      return 1
    fi
    unset PROJECT_ID PUBLIC_ID INBOX_URL EVENT_ID
  fi
  unset RECEIVER_PID
  unset EVENTFORGE_FULL_KEY EVENTFORGE_READ_KEY EVENTFORGE_WEBHOOK_SECRET PROVIDER_WEBHOOK_SECRET REPLAY_KEY
}
if eventforge_demo_cleanup; then
  echo 'Configured API resources removed; test credentials cleared from this shell'
else
  echo 'Cleanup stopped. Fix the request error and retry this step with retained state.' >&2
fi
unset -f eventforge_demo_cleanup
```

`DELETE /api/v1/projects/{projectId}`

## Provider references

- [GitHub signature validation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)
- [GitHub failed deliveries](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries)
