EventForge Shopify guide
Test event: orders/create (development store)
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.
export EF_BASE_URL="https://event-forge.app"
export PROVIDER="shopify"
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_projectConfigure 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 an owner-controlled development store and a dedicated test app with permission for orders/create and its required order data. Subscribe the app to orders/create over HTTPS, with INBOX_URL as the URI and webhook API version 2026-10; install the test app on that development store. The app configuration snippet below uses a placeholder: replace it locally with INBOX_URL, then apply it only to that test app. Load the app's client secret as PROVIDER_WEBHOOK_SECRET. Do not use a store-admin-created notification's different signing key with this app example. After the receiver and rule exist, create one test order in the development store using its free test payment mode. The named event is orders/create, not a production purchase.
# In the dedicated test app's shopify.app.toml; replace the URI locally:
[webhooks]
api_version = "2026-10"
[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "https://event-forge.app/inbox/REPLACE_PUBLIC_ID/shopify"
# Record the installed tooling version:
# shopify version
# Apply this configuration now; create the test order only at Receive and diagnose.
# Optional fixture only (not subscription acceptance):
# SHOPIFY_FLAG_CLIENT_SECRET can be set locally to the test app secret.
# shopify app webhook trigger --topic orders/create --api-version 2026-10 --address "$INBOX_URL"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.
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// 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.
# 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_ruleReceive 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.
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.
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.
rm receiver.failAuthorize 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.
# 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
fiInspect 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.
# Run only after GET event detail confirms the new Replay chain succeeded.
tail -n 10 receiver.logLimits 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 order observation by signed body id + created_at, scoped to one test app/store per process. X-Shopify-Webhook-Id, X-Shopify-Event-Id, X-Shopify-Topic and X-Shopify-Shop-Domain are not authenticated by the body HMAC. A different unsigned ID or topic cannot create another effect. An order update can carry the same order-shaped body: this example does not prove an orders/create topic or authorize fulfillment. Do not multiplex stores solely by an unsigned shop-domain header.
Shopify's body HMAC does not authenticate its delivery ID or a universal five-minute ingress window. created_at is used only as part of the sample identity, not proof of fresh ingress. A first-seen old signed order can count as a new observation. For consequential processing, verify current order state and the store/account binding through an authorized provider API.
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.
Actual Shopify HTTPS subscriptions retry failed inbox requests according to Shopify's delivery policy. A downstream receiver failure after the inbox accepted the request is handled by EventForge. Shopify CLI webhook trigger sends a fixed sample, does not retry and cannot validate subscriptions: creating a test order in the subscribed development store is required for external acceptance.
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.
# 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_cleanupDELETE /api/v1/projects/{projectId}