# Quickstart: Watch On-Chain Events
Source: https://docs.chain.link/crec/getting-started/quickstart-watch-events
Last Updated: 2026-08-31

> For the complete documentation index, see [llms.txt](/llms.txt).

<CrecCommon callout="beta" />

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](#9-full-program) if you prefer to copy-paste-and-go.

> **NOTE: Before you start**
>
> Make sure you have completed [Authentication](/crec/getting-started/authentication) and that `client.ListNetworks()`
> returns at least one network.

## 0. Set up your project

If you already have a Go module (for example by following [SDK Installation](/crec/getting-started/sdk-installation)), skip ahead to [Part 1](#1-imports-and-configuration).

Otherwise, scaffold a fresh project from your terminal:

```bash
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](#9-full-program)). Dependencies are fetched at the run step in [Part 9](#9-full-program), once the file has imports for `go mod tidy` to resolve.

> **NOTE: Why pin `go 1.25.5` and `toolchain=go1.25.9`**
>
> The CRE Connect SDK requires Go **1.25.3+** and the DTA extension requires Go **1.25.5+**. Pinning both directives
> lets `go mod tidy` and `go run` work on machines where the system Go is older. `GOTOOLCHAIN=auto` (the default since
> Go 1.21) will fetch the matching toolchain transparently.

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

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

```go
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`](/crec/reference/error-handling) on every call. Your Organization ID looks like `org_example00000000000`; see [Prerequisites](/crec/getting-started/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.

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

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

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

```go
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`](/crec/guides/events/poll-and-search) for historical queries.

```go
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](/crec/guides/events/verify-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`.

> **NOTE: Why we dedupe by EventId**
>
> `Events.Poll` is offset-paginated under the hood. Called with `nil` parameters it returns the **latest `limit` events
> in descending offset order**: the *same* batch every call until newer events arrive. It is **not** a streaming cursor.
> For a quickstart that just wants to see `targetEvents` distinct events, deduping by `EventId` (each event has a unique
> UUID) is the simplest correct loop. A long-running consumer should instead track the highest `Headers.Offset` it has
> processed and pass it back as the `Offset` filter; see [Poll and Search Events](/crec/guides/events/poll-and-search)
> for that pattern.

> **CAUTION: Always verify before processing**
>
> Skipping the `Verify` call defeats the entire point of CRE Connect. In production, treat any unverified event as if it
> never happened.

## 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:

```bash
❯ 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 <a href="https://sepolia.etherscan.io/token/0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14#events" target="_blank" rel="noopener noreferrer">WETH token on Sepolia Etherscan</a> 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](/crec/guides/watchers/manage-lifecycle) and [Common symptoms](/crec/reference/error-handling#common-symptoms-what-to-do).

## 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`](/crec/guides/events/poll-and-search) 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.

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

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 <a href="https://app.chain.link" target="_blank" rel="noopener noreferrer">app.chain.link</a>), then run:

```bash
go mod tidy
export CREC_API_KEY=<your-api-key>
export CREC_ORG_ID=<your-org-id>      # e.g. org_example00000000000
go run .
```

> **CAUTION: If `go mod tidy` errors on `gogo/protobuf v1.3.3`**
>
> Use `go mod tidy -e` to ignore the error and continue. It's a known indirect, test-only dependency that is never
> reached by your code; it will be removed in a future SDK release.

## Next steps

- **Send a transaction**: continue with [Quickstart: Send Your First Operation](/crec/getting-started/quickstart-send-operation).
- Watch your **own** contract instead of WETH by changing `contractAddr` and the ABI fragments; see [Create a Watcher with a Custom ABI](/crec/guides/watchers/create-with-abi).
- Filter to specific addresses or replay history with `client.Events.SearchEvents`; covered in [Poll and Search Events](/crec/guides/events/poll-and-search).
- Learn the lifecycle states in [Concepts → Watchers](/crec/concepts/watchers) and [Reference → Lifecycles](/crec/reference/lifecycles).