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 ABI has Type: "event" (watchers.ErrInvalidABIType otherwise);
  • every name in Events exists in ABI (watchers.ErrEventNotInABI otherwise);
  • 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:

ErrorMeaning
watchers.ErrWaitForActiveTimeoutThe deadline elapsed while still in pending.
watchers.ErrWatcherDeploymentFailedThe workflow failed to deploy.
watchers.ErrWatcherIsArchiving / ErrWatcherAlreadyArchivedSomeone 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 Transfer events match the from, to, and value triplets 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 event and ...no new events log lines are the loop telling you "still healthy, just nothing new on chain".
  • If the loop only prints ...no new events this cycle for an extended period and the Etherscan link above shows recent transfers, the watcher likely isn't active. Inspect it with client.Watchers.Get and look at status_reason; see Manage Watcher Lifecycle and Common symptoms.

What just happened

End-to-end, every Transfer event traveled this path:

  1. The Sepolia WETH contract emitted a Transfer log on-chain, paid for by some random user, completely independent of your code.
  2. 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.
  3. The CRE Connect API stored the event with its OCR proof attached, then served it on your watcher's channel.
  4. client.Events.Poll pulled a page of events for the channel using offset-based pagination; your in-memory dedupe map skipped anything already processed.
  5. client.Events.Verify re-derived the event hash, looked up the OCR proof, and checked f+1 DON signatures (bound to your tenant via WithOrgID). Events without proofs surfaced as ErrNoOCRProofs so the loop could re-poll instead of trusting them.
  6. Your app decoded the verified envelope into an EVMEvent and unpacked the typed Transfer(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

Get the latest Chainlink content straight to your inbox.