See the question and my original answer on StackOverflow

Events in .NET are seen as Connection Point on native side. You can use them with ATL as described here ATL Connection Points and Event Handling Principles

So here's a small recap.

Here is your C# class

namespace Eggplant
{
    [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
    [Guid("C61C7C47-BB98-4DF3-BC61-7CA9430EDE7A")]
    [ComVisible(true)]
    public interface IEggplantClientEvents
    {
        [DispId(1)]
        void Completed(string text);
    }

    [Guid("0a805b99-756a-493c-96b7-063400f171ed")]
    [ComVisible(true)]
    [ClassInterface(ClassInterfaceType.None)]
    [ComSourceInterfaces(typeof(IEggplantClientEvents))]
    [ProgId("EggplantClient.CEggplantClient")]
    public class CEggplantClient
    {
        [ComVisible(false)] public delegate void CompletedDelegate(string text);
        public event CompletedDelegate Completed;

        public CEggplantClient()
        {
            // wait 2 seconds and then call every second
            Task.Delay(2000).ContinueWith(async t =>
            {
                do
                {
                    Completed?.Invoke("Time is " + DateTime.Now);
                    await Task.Delay(1000);
                }
                while (true);
            });
        }
    }
}

You can register your C# class like this (will create an Eggplant.tlb file) with .NET Framework:

%windir%\Microsoft.NET\Framework64\v4.0.30319\RegAsm.exe Eggplant.dll /codebase /tlb

note: with .NET core and .NET 5/6/7+ you'll have to build your own .TLB, or copy your C# .NET Core code into a .NET Framework .dll and use this ...

Here is your C/C++ code (forward refs omitted):

#include <windows.h>
#include <stdio.h>
#include <atlbase.h>
#include <atlcom.h>

#import "D:\kilroy\was\here\Eggplant\bin\Debug\Eggplant.tlb" // import the tlb

using namespace Eggplant; // #import by default puts generated code in a specific namespace

int main()
{
    CoInitialize(nullptr);
    {
        CComPtr<IUnknown> app;
        if (SUCCEEDED(app.CoCreateInstance(__uuidof(CEggplantClient))))
        {
            // sink events
            auto sink = new CEggplantClientEventsSink();
            if (SUCCEEDED(sink->Connect(app)))
            {
                // this message box allows us to wait while events arrive
                MessageBox(nullptr, L"Click to stop listening", L"Events", MB_OK);
            }
        }
    }
    CoUninitialize();
    return 0;
}

// this is the event sink
class CEggplantClientEventsSink : public CDispInterfaceBase<IEggplantClientEvents>
{
public:
    CEggplantClientEventsSink() { }

    HRESULT Invoke(DISPID dispid, DISPPARAMS* pdispparams, VARIANT* pvarResult)
    {
        switch (dispid)
        {
        case 1: // the Completed DISPID value
            wprintf(L"Completed called text:%s\n", pdispparams->rgvarg[0].bstrVal);
            break;
        }
        return S_OK;
    }
};

// this is a generic support class to hook IDispatch events
// adapted from here: https://devblogs.microsoft.com/oldnewthing/20130612-00/?p=4103
template<typename DispInterface>
class CDispInterfaceBase : public DispInterface
{
    LONG m_cRef;
    CComPtr<IConnectionPoint> m_spcp;
    DWORD m_dwCookie;

public:
    CDispInterfaceBase() : m_cRef(1), m_dwCookie(0) { }

    // IUnknown
    IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv)
    {
        *ppv = nullptr;
        HRESULT hr = E_NOINTERFACE;
        if (riid == IID_IUnknown || riid == IID_IDispatch || riid == __uuidof(DispInterface))
        {
            *ppv = static_cast<DispInterface*>(static_cast<IDispatch*>(this));
            AddRef();
            hr = S_OK;
        }
        return hr;
    }

    IFACEMETHODIMP_(ULONG) AddRef() { return InterlockedIncrement(&m_cRef); }
    IFACEMETHODIMP_(ULONG) Release() { LONG cRef = InterlockedDecrement(&m_cRef); if (!cRef) delete this; return cRef; }

    // IDispatch
    IFACEMETHODIMP GetTypeInfoCount(UINT* pctinfo) { *pctinfo = 0; return E_NOTIMPL; }
    IFACEMETHODIMP GetTypeInfo(UINT iTInfo, LCID lcid, ITypeInfo** ppTInfo) { *ppTInfo = nullptr; return E_NOTIMPL; }
    IFACEMETHODIMP GetIDsOfNames(REFIID, LPOLESTR* rgszNames, UINT cNames, LCID lcid, DISPID* rgDispId) { return E_NOTIMPL; }
    IFACEMETHODIMP Invoke(DISPID dispid, REFIID riid, LCID lcid, WORD wFlags, DISPPARAMS* pdispparams, VARIANT* pvarResult, EXCEPINFO* pexcepinfo, UINT* puArgErr)
    {
        if (pvarResult) VariantInit(pvarResult);
        return Invoke(dispid, pdispparams, pvarResult);
    }

    virtual HRESULT Invoke(DISPID dispid, DISPPARAMS* pdispparams, VARIANT* pvarResult) = 0;

public:
    HRESULT Connect(IUnknown* punk)
    {
        CComPtr<IConnectionPointContainer> spcpc;
        HRESULT  hr = punk->QueryInterface(IID_PPV_ARGS(&spcpc));
        if (SUCCEEDED(hr)) hr = spcpc->FindConnectionPoint(__uuidof(DispInterface), &m_spcp);
        if (SUCCEEDED(hr)) hr = m_spcp->Advise(this, &m_dwCookie);
        return hr;
    }

    void Disconnect()
    {
        if (m_dwCookie)
        {
            m_spcp->Unadvise(m_dwCookie);
            m_spcp.Release();
            m_dwCookie = 0;
        }
    }
};

And this is the result:

.NET COM Events