Quickstart: Watch On-Chain Events
A watcher is a CRE Connect resource that monitors a contract on a chain and turns matching logs into verifiable events on your channel. In this quickstart you attach a watcher to the Sepolia WETH token (the WETH9 contract used by Uniswap V3) using a custom ABI, then poll the channel, cryptographically verify each event, and decode it into a Go map. You never sign a transaction: the watcher only reads.
Success looks like this: once the watcher is active, your terminal prints a "listening for Transfer events" line, then 3 verified WETH Transfer events with their from, to, and value fields, then the program exits cleanly. End-to-end runtime depends mostly on how often the WETH contract emits Transfer events, which is bursty by nature. The loop prints ...waiting for the next event and ...no new events heartbeats so you can tell it hasn't frozen.
Sections 0-8 build a single main.go piece by piece: construct the client, create a channel, attach the watcher, wait for it to become active, then poll, verify, and decode. The complete file is in 9. Full program if you prefer to copy-paste-and-go.
0. Set up your project
If you already have a Go module (for example by following SDK Installation), skip ahead to Part 1.
Otherwise, scaffold a fresh project from your terminal:
mkdir crec-watch-events && cd crec-watch-events
go mod init crec-watch-events
go mod edit -go=1.25.5 -toolchain=go1.25.9
touch main.go
Open main.go in your editor. You will paste the snippets from Parts 1-8 into it in order (or, if you'd rather skip ahead, paste the full program from Part 9). Dependencies are fetched at the run step in Part 9, once the file has imports for go mod tidy to resolve.
1. Imports and configuration
The first snippet is the package declaration, the imports, and the constants that drive the rest of the file. Paste it at the very top of main.go:
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"time"
"github.com/smartcontractkit/crec-sdk"
"github.com/smartcontractkit/crec-sdk/channels"
"github.com/smartcontractkit/crec-sdk/events"
"github.com/smartcontractkit/crec-sdk/watchers"
)
const (
chainSelector = "16015286601757825753" // ethereum-testnet-sepolia
contractAddr = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" // Sepolia WETH (Uniswap V3 deployment)
watcherName = "quickstart-watcher"
targetEvents = 3 // exit after this many verified events
)
The chain selector is the same string you would pass anywhere else in the SDK. Use client.ListNetworks(...) to discover the value for any other supported network.
2. Construct the client
Everything from here on goes inside func main() { ... }. Add the function body and start with the client constructor:
client, err := crec.NewClient(
"https://cre-connect.api.chain.link/v1",
os.Getenv("CREC_API_KEY"),
crec.WithOrgID(os.Getenv("CREC_ORG_ID")),
)
if err != nil {
log.Fatal(err)
}
The SDK ships with the production DON signer addresses baked in, so the Off-Chain Reporting (OCR) signature check is configured automatically. You still need to tell the client which org's events you trust: that's what crec.WithOrgID(...) does. It derives your unique workflow-owner address from the org ID and binds every event hash to your tenant. Skip it and client.Events.Verify(...) returns events.ErrOrgIDOrWorkflowOwnerReq on every call. Your Organization ID looks like org_example00000000000; see Prerequisites for where to find it.
3. Create a channel
A channel is a logical grouping for the watchers and events that belong to a single application. Pick a descriptive name: channel names must be unique within your tenant.
ctx := context.Background()
ch, err := client.Channels.Create(ctx, channels.CreateInput{
Name: "quickstart-watch-events",
})
if err != nil {
log.Fatalf("create channel: %v", err)
}
fmt.Printf("created channel %s\n", ch.ChannelId)
If you already have a channel you want to reuse, list and pick one:
existing, _, _ := client.Channels.List(ctx, channels.ListInput{})
for _, c := range existing {
fmt.Println(c.ChannelId, c.Name)
}
4. Attach a watcher with a custom ABI
For arbitrary contracts (anything not covered by a CRE Connect extension) use CreateWithABI. You provide the contract address, the chain selector, the list of event names to monitor, and the matching ABI fragments.
abi := []watchers.EventABI{
{
Type: "event",
Name: "Transfer",
Inputs: []watchers.EventABIInput{
{Indexed: true, Name: "from", Type: "address", InternalType: "address"},
{Indexed: true, Name: "to", Type: "address", InternalType: "address"},
{Indexed: false, Name: "value", Type: "uint256", InternalType: "uint256"},
},
},
}
w, err := client.Watchers.CreateWithABI(ctx, ch.ChannelId, watchers.CreateWithABIInput{
Name: watcherName,
ChainSelector: chainSelector,
Address: contractAddr,
Events: []string{"Transfer"},
ABI: abi,
})
if err != nil {
log.Fatalf("create watcher: %v", err)
}
fmt.Printf("watcher %s created (status=%s)\n", w.WatcherId, w.Status)
The SDK validates locally that:
- the channel and chain selector are non-empty;
- every entry in
ABIhasType: "event"(watchers.ErrInvalidABITypeotherwise); - every name in
Eventsexists inABI(watchers.ErrEventNotInABIotherwise); - the watcher name is at least 4 characters after trimming.
5. Wait for the watcher to become active
A watcher is created pending and transitions to active once the underlying CRE workflow has been deployed. WaitForActive polls until the watcher reaches a terminal state or the deadline expires.
active, err := client.Watchers.WaitForActive(ctx, ch.ChannelId, w.WatcherId, 2*time.Minute)
if err != nil {
log.Fatalf("wait for active: %v", err)
}
fmt.Printf("watcher %s is %s\n", active.WatcherId, active.Status)
If the workflow deployment fails, WaitForActive returns one of the documented sentinel errors so you can react appropriately:
| Error | Meaning |
|---|---|
watchers.ErrWaitForActiveTimeout | The deadline elapsed while still in pending. |
watchers.ErrWatcherDeploymentFailed | The workflow failed to deploy. |
watchers.ErrWatcherIsArchiving / ErrWatcherAlreadyArchived | Someone archived the watcher concurrently. |
6. Poll, verify, and decode
Once active, poll the channel. The watcher emits watcher.event envelopes for every observed WETH Transfer. This quickstart exits after targetEvents verified events so you have a clear "done" moment; in production you would loop indefinitely or use SearchEvents for historical queries.
seen := 0
processed := map[string]bool{}
const pollEvery = 5 * time.Second
fmt.Printf("listening for Transfer events on Sepolia WETH — waiting for the first event...\n")
for seen < targetEvents {
polled, _, err := client.Events.Poll(ctx, ch.ChannelId, nil)
if err != nil {
log.Fatalf("poll: %v", err)
}
progressBefore := seen
for _, event := range polled {
if processed[event.EventId.String()] {
continue
}
// 1. Verify. Newly-active watchers can briefly return events
// before the DON has produced an OCR proof. Treat that as
// "not yet, try again" rather than a hard failure: don't
// mark the event as processed, so the next poll sees it
// again with the proof attached.
verified, err := client.Events.Verify(&event)
if errors.Is(err, events.ErrNoOCRProofs) {
continue
}
// From here on the event is final — accepted or permanently
// rejected, we won't reconsider it.
processed[event.EventId.String()] = true
if err != nil {
log.Printf("verify failed for %s: %v", event.EventId, err)
continue
}
if !verified {
log.Printf("event %s did not verify — skipping", event.EventId)
continue
}
// 2. Unwrap the polymorphic Event.Payload to the watcher payload.
watcherPayload, err := event.Payload.AsWatcherEventPayload()
if err != nil {
log.Printf("not a watcher payload: %v", err)
continue
}
// 3. Decode the base64 VerifiableEvent into a structured form.
verifiable, err := client.Events.DecodeVerifiableEvent(&watcherPayload)
if err != nil {
log.Printf("decode verifiable: %v", err)
continue
}
// 4. Unwrap the chain-event union to its EVM-specific form and
// read the decoded log params (from, to, value).
evmEvent, err := verifiable.ChainEvent.AsEVMEvent()
if err != nil {
log.Printf("not an EVM event: %v", err)
continue
}
if evmEvent.Params == nil {
continue
}
decoded := *evmEvent.Params
seen++
fmt.Printf("[%d/%d] Transfer from=%v to=%v value=%v\n",
seen, targetEvents, decoded["from"], decoded["to"], decoded["value"])
if seen >= targetEvents {
break
}
}
if seen < targetEvents {
if seen == progressBefore {
fmt.Printf(" ...no new events this cycle, still listening (%d/%d so far) — re-polling in %s\n",
seen, targetEvents, pollEvery)
} else {
fmt.Printf(" ...waiting for the next event (%d/%d so far) — re-polling in %s\n",
seen, targetEvents, pollEvery)
}
time.Sleep(pollEvery)
}
}
fmt.Printf("done — verified %d Transfer events\n", seen)
Don't kill the program during one of those quiet stretches: the watcher is healthy and connected, it's just waiting for the next on-chain Transfer to land.
Verify does two things: it checks the OCR signatures against the SDK's built-in production DON signer set (no extra config needed), and it confirms the event hash is bound to your tenant's workflow owner. That's why we passed crec.WithOrgID(...) in step 2. For multi-org or per-event verification flows, see Verify Event Signatures.
The decode pipeline has four steps because CRE Connect events are deliberately polymorphic: the same Event envelope carries watcher events today and will carry other payload kinds (e.g. operation status, future non-EVM chain events) in the future. To pull out the EVM log params (from, to, value) you walk: Event.Payload → AsWatcherEventPayload() → DecodeVerifiableEvent() → ChainEvent.AsEVMEvent() → *Params. The Params map is keyed by the input names you declared in the ABI you passed to CreateWithABI.
7. Expected output
A representative end-to-end run looks like this. Most of the wall-clock time is spent waiting on Sepolia WETH to actually emit the next Transfer, with the ...no new events heartbeat confirming the loop is healthy in between:
❯ go run .
2026/04/27 11:38:06 INFO Channel created successfully channel_id=2bf7840e-3301-427d-af79-38047fc3657b name=quickstart-watch-events
created channel 2bf7840e-3301-427d-af79-38047fc3657b
2026/04/27 11:38:06 INFO Watcher created successfully watcher_id=41b6b6cc-5393-442f-904e-c9af2eb4d4b8
watcher 41b6b6cc-5393-442f-904e-c9af2eb4d4b8 created (status=pending)
2026/04/27 11:38:09 INFO Watcher is now active
watcher 41b6b6cc-5393-442f-904e-c9af2eb4d4b8 is active
listening for Transfer events on Sepolia WETH — waiting for the first event...
...no new events this cycle, still listening (0/3 so far) — re-polling in 5s
...no new events this cycle, still listening (0/3 so far) — re-polling in 5s
...no new events this cycle, still listening (0/3 so far) — re-polling in 5s
...(many more `...no new events` lines elided — about 2 minutes of polling)
[1/3] Transfer from=0x4eBDcF7071191eE0Cc8386C8F4799Ca468619C67 to=0x3Ee4db9dD1f563fFf53b7919CD48803668b9FF6f value=2242341932209194
...waiting for the next event (1/3 so far) — re-polling in 5s
...no new events this cycle, still listening (1/3 so far) — re-polling in 5s
...(another quiet stretch)
[2/3] Transfer from=0x3498c861362f0868CC6AAfC25Bf3cBf9277e2Da9 to=0x3Ee4db9dD1f563fFf53b7919CD48803668b9FF6f value=88988505494225
...waiting for the next event (2/3 so far) — re-polling in 5s
...no new events this cycle, still listening (2/3 so far) — re-polling in 5s
...(another quiet stretch)
[3/3] Transfer from=0x8E97C8cD857FFB7f90c6075ce7C56398998C25D9 to=0x171Fab1099EAa24dF9738De7F235994a48b83BDF value=407
done — verified 3 Transfer events
2026/04/27 11:43:48 INFO Watcher archive initiated (async) watcher_id=41b6b6cc-5393-442f-904e-c9af2eb4d4b8
2026/04/27 11:43:48 INFO Channel archived successfully channel_id=2bf7840e-3301-427d-af79-38047fc3657b
8. Verify it worked
Cross-check the events you just printed against public Sepolia WETH token activity:
- Open the WETH token on Sepolia Etherscan and confirm the most recent
Transferevents match thefrom,to, andvaluetriplets your program printed. - The DON-signed events your loop verified are the same logs Etherscan is rendering, but yours arrived with a cryptographic proof you re-checked locally with
Events.Verify.
If the loop hasn't reached 3 events yet:
- That's almost always fine. Sepolia WETH Transfer activity is bursty: quiet stretches between events are normal. The
...waiting for the next eventand...no new eventslog lines are the loop telling you "still healthy, just nothing new on chain". - If the loop only prints
...no new events this cyclefor an extended period and the Etherscan link above shows recent transfers, the watcher likely isn'tactive. Inspect it withclient.Watchers.Getand look atstatus_reason; see Manage Watcher Lifecycle and Common symptoms.
What just happened
End-to-end, every Transfer event traveled this path:
- The Sepolia WETH contract emitted a
Transferlog on-chain, paid for by some random user, completely independent of your code. - The Chainlink DON observed the log on Sepolia and produced an Off-Chain Reporting (OCR) signature attesting that f+1 nodes saw exactly this event.
- The CRE Connect API stored the event with its OCR proof attached, then served it on your watcher's channel.
client.Events.Pollpulled a page of events for the channel using offset-based pagination; your in-memory dedupe map skipped anything already processed.client.Events.Verifyre-derived the event hash, looked up the OCR proof, and checked f+1 DON signatures (bound to your tenant viaWithOrgID). Events without proofs surfaced asErrNoOCRProofsso the loop could re-poll instead of trusting them.- Your app decoded the verified envelope into an
EVMEventand unpacked the typedTransfer(from, to, value)parameters for printing.
In production you would either keep polling or use SearchEvents for historical reads.
9. Full program
Here is everything wired together as a single main.go. The trailing Archive calls clean up the watcher and channel so they stop consuming DON capacity for your tenant. Drop them if you want to keep the watcher running between runs.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"time"
"github.com/smartcontractkit/crec-sdk"
"github.com/smartcontractkit/crec-sdk/channels"
"github.com/smartcontractkit/crec-sdk/events"
"github.com/smartcontractkit/crec-sdk/watchers"
)
const (
chainSelector = "16015286601757825753" // ethereum-testnet-sepolia
contractAddr = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" // Sepolia WETH (Uniswap V3 deployment)
watcherName = "quickstart-watcher"
targetEvents = 3 // exit after this many verified events
)
func main() {
client, err := crec.NewClient(
"https://cre-connect.api.chain.link/v1",
os.Getenv("CREC_API_KEY"),
crec.WithOrgID(os.Getenv("CREC_ORG_ID")),
)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
ch, err := client.Channels.Create(ctx, channels.CreateInput{
Name: "quickstart-watch-events",
})
if err != nil {
log.Fatalf("create channel: %v", err)
}
fmt.Printf("created channel %s\n", ch.ChannelId)
abi := []watchers.EventABI{
{
Type: "event",
Name: "Transfer",
Inputs: []watchers.EventABIInput{
{Indexed: true, Name: "from", Type: "address", InternalType: "address"},
{Indexed: true, Name: "to", Type: "address", InternalType: "address"},
{Indexed: false, Name: "value", Type: "uint256", InternalType: "uint256"},
},
},
}
w, err := client.Watchers.CreateWithABI(ctx, ch.ChannelId, watchers.CreateWithABIInput{
Name: watcherName,
ChainSelector: chainSelector,
Address: contractAddr,
Events: []string{"Transfer"},
ABI: abi,
})
if err != nil {
log.Fatalf("create watcher: %v", err)
}
fmt.Printf("watcher %s created (status=%s)\n", w.WatcherId, w.Status)
active, err := client.Watchers.WaitForActive(ctx, ch.ChannelId, w.WatcherId, 2*time.Minute)
if err != nil {
log.Fatalf("wait for active: %v", err)
}
fmt.Printf("watcher %s is %s\n", active.WatcherId, active.Status)
seen := 0
processed := map[string]bool{}
const pollEvery = 5 * time.Second
fmt.Printf("listening for Transfer events on Sepolia WETH — waiting for the first event...\n")
for seen < targetEvents {
polled, _, err := client.Events.Poll(ctx, ch.ChannelId, nil)
if err != nil {
log.Fatalf("poll: %v", err)
}
progressBefore := seen
for _, event := range polled {
if processed[event.EventId.String()] {
continue
}
verified, err := client.Events.Verify(&event)
if errors.Is(err, events.ErrNoOCRProofs) {
continue
}
processed[event.EventId.String()] = true
if err != nil {
log.Printf("verify failed for %s: %v", event.EventId, err)
continue
}
if !verified {
log.Printf("event %s did not verify — skipping", event.EventId)
continue
}
watcherPayload, err := event.Payload.AsWatcherEventPayload()
if err != nil {
log.Printf("not a watcher payload: %v", err)
continue
}
verifiable, err := client.Events.DecodeVerifiableEvent(&watcherPayload)
if err != nil {
log.Printf("decode verifiable: %v", err)
continue
}
evmEvent, err := verifiable.ChainEvent.AsEVMEvent()
if err != nil {
log.Printf("not an EVM event: %v", err)
continue
}
if evmEvent.Params == nil {
continue
}
decoded := *evmEvent.Params
seen++
fmt.Printf("[%d/%d] Transfer from=%v to=%v value=%v\n",
seen, targetEvents, decoded["from"], decoded["to"], decoded["value"])
if seen >= targetEvents {
break
}
}
if seen < targetEvents {
if seen == progressBefore {
fmt.Printf(" ...no new events this cycle, still listening (%d/%d so far) — re-polling in %s\n",
seen, targetEvents, pollEvery)
} else {
fmt.Printf(" ...waiting for the next event (%d/%d so far) — re-polling in %s\n",
seen, targetEvents, pollEvery)
}
time.Sleep(pollEvery)
}
}
fmt.Printf("done — verified %d Transfer events\n", seen)
if _, err := client.Watchers.Archive(ctx, ch.ChannelId, w.WatcherId); err != nil {
log.Printf("archive watcher: %v", err)
}
if _, err := client.Channels.Archive(ctx, ch.ChannelId); err != nil {
log.Printf("archive channel: %v", err)
}
}
Run it from inside your project folder. Fetch dependencies, export your API key and Organization ID (the latter is in the header of your org's Organization page on app.chain.link), then run:
go mod tidy
export CREC_API_KEY=<your-api-key>
export CREC_ORG_ID=<your-org-id> # e.g. org_example00000000000
go run .
Next steps
- Send a transaction: continue with Quickstart: Send Your First Operation.
- Watch your own contract instead of WETH by changing
contractAddrand the ABI fragments; see Create a Watcher with a Custom ABI. - Filter to specific addresses or replay history with
client.Events.SearchEvents; covered in Poll and Search Events. - Learn the lifecycle states in Concepts → Watchers and Reference → Lifecycles.