# EventForge Stripe guide

Test event: payment_intent.succeeded (sandbox snapshot)

[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="stripe"
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 dedicated owner-controlled Stripe sandbox and a locally supplied sandbox API key in STRIPE_TEST_API_KEY, permitted to create/delete test webhook endpoints and trigger test PaymentIntents. The command below creates a separate snapshot webhook endpoint with api_version=2025-12-15.clover for payment_intent.succeeded and INBOX_URL. It saves that endpoint's signing secret locally as PROVIDER_WEBHOOK_SECRET without printing it. Repeating this setup refuses to replace a recorded endpoint. An uncertain creation retains STRIPE_ENDPOINT_SETUP_KEY: retry the identical request with the same sandbox key and INBOX_URL within 24 hours. After that, or before changing parameters, inspect the sandbox console and resolve any created endpoint before clearing the setup key. This pins the payload version required by stripe-go/v84 v84.1.0 independently of the account default. The CLI trigger below separately pins its fixture API requests to the same Clover version; a newer account default can reject parameters used by an older CLI before any event is created. For a historical version, use the endpoint API rather than relying on the Workbench version picker. Do not change account-wide API versions or EventForge billing webhooks. Do not run stripe listen for this route: its default is the account API version, while --latest is a moving version, and neither pins Clover. The listener's signing secret also differs from this endpoint's secret. The API-created endpoint appears in Workbench for delivery inspection and sandbox resend.

```bash
# Run in the current Bash shell; this helper preserves its options and existing state.
eventforge_demo_configure_stripe() {
  local endpoint_json endpoint_id endpoint_secret
  if [[ -n "${STRIPE_ENDPOINT_ID:-}" ]]; then
    echo 'Endpoint already recorded; keep it or use Remove only the Stripe endpoint before replacing it' >&2
    return 1
  fi
  if [[ "${INBOX_URL:-}" != https://* ]]; then
    echo 'Create the project and use its publicly reachable HTTPS INBOX_URL first' >&2
    return 1
  fi
  case "${STRIPE_TEST_API_KEY:-}" in
    sk_test_*|rk_test_*) ;;
    *) echo 'Load a sandbox Stripe API key locally before this step' >&2; return 1 ;;
  esac
  if [[ -z "${STRIPE_ENDPOINT_SETUP_KEY:-}" ]]; then
    STRIPE_ENDPOINT_SETUP_KEY=$(uuidgen) || unset STRIPE_ENDPOINT_SETUP_KEY
  fi
  if [[ -z "${STRIPE_ENDPOINT_SETUP_KEY:-}" ]]; then
    echo 'Create a setup UUID before continuing' >&2; return 1
  fi
  export STRIPE_ENDPOINT_SETUP_KEY
  if ! endpoint_json=$(curl --fail -sS https://api.stripe.com/v1/webhook_endpoints \
    -H "Authorization: Bearer $STRIPE_TEST_API_KEY" -H "Idempotency-Key: $STRIPE_ENDPOINT_SETUP_KEY" \
    --data-urlencode "url=$INBOX_URL" \
    -d 'enabled_events[]=payment_intent.succeeded' -d 'api_version=2025-12-15.clover'); then
    echo 'Endpoint creation failed; check sandbox key permissions and connection' >&2
    return 1
  fi
  if ! endpoint_id=$(printf '%s' "$endpoint_json" | jq -er '.id | select(type == "string" and startswith("we_"))'); then
    echo 'Unexpected endpoint response; inspect the sandbox endpoints before retrying' >&2
    return 1
  fi
  # Preserve the new ID for cleanup even if the returned configuration is unexpected.
  export STRIPE_ENDPOINT_ID="$endpoint_id"
  if ! printf '%s' "$endpoint_json" | jq -e '.livemode == false and .api_version == "2025-12-15.clover"' >/dev/null; then
    echo 'Unexpected endpoint configuration; use Remove only the Stripe endpoint before retrying' >&2
    return 1
  fi
  if ! endpoint_secret=$(printf '%s' "$endpoint_json" | jq -er '.secret | select(type == "string" and length > 0)'); then
    echo 'No signing secret returned; use Remove only the Stripe endpoint before retrying' >&2
    return 1
  fi
  export PROVIDER_WEBHOOK_SECRET="$endpoint_secret"
}
if eventforge_demo_configure_stripe; then
  echo 'Sandbox endpoint configured; signing secret loaded locally'
else
  echo 'Stop here. Fix the setup error before continuing; your shell state is preserved.' >&2
fi
unset -f eventforge_demo_configure_stripe
```

## Remove only the Stripe endpoint (optional replacement)

Skip this step during the normal demo. To replace a recorded endpoint after a setup error, stop the old receiver first, run this block, then repeat provider setup and restart the receiver with the new secret and a fresh test event. This retains the EventForge project/rule and sandbox API key. If creation has an uncertain result without an ID, first repeat the same setup request within 24 hours or inspect the sandbox console, restore the created STRIPE_ENDPOINT_ID, and resolve it before clearing the setup key. Never just unset an active endpoint ID.

```bash
# Optional: run only when deliberately replacing the recorded endpoint.
eventforge_demo_remove_stripe_endpoint() {
  if [[ -n "${STRIPE_ENDPOINT_SETUP_KEY:-}" && -z "${STRIPE_ENDPOINT_ID:-}" ]]; then
    echo 'Resolve uncertain endpoint creation by repeating setup with the same key within 24 hours, or inspect the sandbox console before clearing STRIPE_ENDPOINT_SETUP_KEY' >&2
    return 1
  fi
  if [[ -n "${STRIPE_ENDPOINT_ID:-}" ]]; then
    case "${STRIPE_TEST_API_KEY:-}" in
      sk_test_*|rk_test_*) ;;
      *) echo 'Restore a sandbox Stripe API key before retrying cleanup' >&2; return 1 ;;
    esac
    if ! curl --fail-with-body -sS -X DELETE "https://api.stripe.com/v1/webhook_endpoints/$STRIPE_ENDPOINT_ID" \
      -H "Authorization: Bearer $STRIPE_TEST_API_KEY"; then
      echo 'Stripe endpoint deletion failed; endpoint ID and key are retained' >&2
      return 1
    fi
    unset STRIPE_ENDPOINT_ID STRIPE_ENDPOINT_SETUP_KEY PROVIDER_WEBHOOK_SECRET
  fi
}
if eventforge_demo_remove_stripe_endpoint; then
  echo 'Recorded endpoint removed; sandbox API key retained for replacement'
else
  echo 'Stop here; endpoint state is retained for recovery' >&2
fi
unset -f eventforge_demo_remove_stripe_endpoint
```

## 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
# Run now that the receiver and rule exist; the env key selects the same sandbox.
stripe version
STRIPE_API_KEY="$STRIPE_TEST_API_KEY" stripe trigger payment_intent.succeeded \
  --api-version 2025-12-15.clover
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. For Stripe, the same generic 400 also covers an incompatible api_version, a non-snapshot payload, live-mode data or an unsupported demo event. The body field below contains the stored raw text (JSON-escaped by jq). Inspect its api_version locally: this pinned SDK accepts the Clover release train, not an unversioned date or another train. Check the dedicated endpoint is pinned to 2025-12-15.clover and its own signing secret is loaded. Do not disable the SDK's API-version check or age check to hide this failure.

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

Stripe timing: remove receiver.fail immediately after observing the first 503, before the second attempt's nextAttemptAt. That retry can be due only 48 seconds after the first attempt (nominal 60 seconds, jitter 48–72 seconds), so have the read-only inspection command and recovery terminal ready before triggering. The next retry is around 252–348 seconds after the first attempt and can already miss the original 300-second signature window; do not rely on it. If you miss the second attempt or need time to fix configuration, prepare the receiver/rule first. Before a repeat, let the old signature window expire so an older pending chain cannot add an effect during the new run; with synchronized clocks, waiting six minutes after its first receiver 503 is sufficient. For an unchanged receiver configuration, keep the original process running, note its current effect count, and run touch "$FAIL_FILE" to re-arm the induced failure before triggering a new sandbox event. Remove the flag promptly after that event's first 503. Select that new intake and replace EVENT_ID with its EventForge event ID. This is a separate deliberate Replay action: run unset REPLAY_KEY once before its first Replay block so that block generates a new UUID; retain the new key only for retries of that same action. The new event adds one effect to the recorded baseline; its retries and Replay must leave that new total unchanged. Finish successful Replay within the original signature's five-minute age limit. 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 signed Stripe event.id identifies this observation; signed type must be payment_intent.succeeded and livemode must be false. A provider retry with a fresh Stripe signature and the same event.id, or Replay inside tolerance, produces no second effect. Separate Stripe events concerning the same PaymentIntent can have different event IDs: real effects often need an additional object/action business key and authoritative state checks.

stripe-go/v84 v84.1.0 ConstructEvent keeps its default 300-second maximum age check and API release-train check enabled. It checks the timestamp in Stripe-Signature using the SDK clock; it does not supply a symmetric future timestamp bound. Keep clocks synchronized. Never disable or widen tolerance for Replay. A 15-minute delayed delivery and a next-day Replay have a fresh EventForge envelope but the unchanged original Stripe timestamp: this receiver returns 400 with zero effects. Even an already-seen stale event must pass both checks before deduplication. A first-seen captured old body/signature resubmitted to the unverified inbox also fails closed. A fresh envelope cannot prove fresh original ingress. Successful Replay in this guide must happen within five minutes of the original Stripe signature. For a long outage, obtain a new provider-signed attempt through supported sandbox resend and reconcile authoritative provider state under a separate application policy; this demo does not recover old billing events.

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.

Stripe retries failed deliveries to the inbox, signing each provider attempt with a new timestamp. That differs from EventForge's retry of a stored event, which retains the original Stripe-Signature. A successful inbox acceptance means Stripe need not retry even if your downstream receiver later fails. Use Workbench's sandbox resend for an actual registered destination; a CLI trigger creates a new fixture event and is not proof of provider retry behavior.

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 "${STRIPE_ENDPOINT_SETUP_KEY:-}" && -z "${STRIPE_ENDPOINT_ID:-}" ]]; then
    echo 'Resolve uncertain endpoint creation by repeating setup with the same key within 24 hours, or inspect the sandbox console before clearing STRIPE_ENDPOINT_SETUP_KEY' >&2
    return 1
  fi
  if [[ -n "${STRIPE_ENDPOINT_ID:-}" ]]; then
    case "${STRIPE_TEST_API_KEY:-}" in
      sk_test_*|rk_test_*) ;;
      *) echo 'Restore a sandbox Stripe API key before retrying cleanup' >&2; return 1 ;;
    esac
    if ! curl --fail-with-body -sS -X DELETE "https://api.stripe.com/v1/webhook_endpoints/$STRIPE_ENDPOINT_ID" \
      -H "Authorization: Bearer $STRIPE_TEST_API_KEY"; then
      echo 'Stripe endpoint deletion failed; endpoint ID and key are retained' >&2
      return 1
    fi
    unset STRIPE_ENDPOINT_ID STRIPE_ENDPOINT_SETUP_KEY PROVIDER_WEBHOOK_SECRET
  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 STRIPE_TEST_API_KEY STRIPE_ENDPOINT_SETUP_KEY
  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

- [Stripe signatures, duplicates and retries](https://docs.stripe.com/webhooks)
- [Pinned Stripe Go validation source](https://github.com/stripe/stripe-go/blob/v84.1.0/webhook/client.go)
- [Stripe endpoint creation and API version](https://docs.stripe.com/api/webhook_endpoints/create)
- [Stripe webhook versioning](https://docs.stripe.com/webhooks/versioning)
