diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
index 7c6e77d0abfee7f80730f66c9e2caf0324392426..17f8b49dbe883935e3fe7b350c75e67a2ea08ec8 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
@@ -8,6 +8,7 @@ import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.contact.PendingContact;
 import org.briarproject.bramble.api.contact.PendingContactId;
 import org.briarproject.bramble.api.contact.PendingContactState;
+import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent;
 import org.briarproject.bramble.api.crypto.KeyPair;
 import org.briarproject.bramble.api.crypto.PublicKey;
 import org.briarproject.bramble.api.crypto.SecretKey;
@@ -15,24 +16,35 @@ import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.event.EventListener;
 import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.identity.AuthorInfo;
 import org.briarproject.bramble.api.identity.IdentityManager;
 import org.briarproject.bramble.api.identity.LocalAuthor;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
 import org.briarproject.bramble.api.transport.KeyManager;
 
 import java.security.GeneralSecurityException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 import javax.inject.Inject;
 
+import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
+import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
 import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.OURSELVES;
@@ -43,24 +55,31 @@ import static org.briarproject.bramble.util.StringUtils.toUtf8;
 
 @ThreadSafe
 @NotNullByDefault
-class ContactManagerImpl implements ContactManager {
+class ContactManagerImpl implements ContactManager, EventListener {
 
 	private final DatabaseComponent db;
 	private final KeyManager keyManager;
 	private final IdentityManager identityManager;
 	private final PendingContactFactory pendingContactFactory;
+	private final EventBus eventBus;
 
-	private final List<ContactHook> hooks;
+	private final List<ContactHook> hooks = new CopyOnWriteArrayList<>();
+	private final Object statesLock = new Object();
+	@GuardedBy("statesLock")
+	private final Map<PendingContactId, PendingContactState> states =
+			new HashMap<>();
 
 	@Inject
-	ContactManagerImpl(DatabaseComponent db, KeyManager keyManager,
+	ContactManagerImpl(DatabaseComponent db,
+			KeyManager keyManager,
 			IdentityManager identityManager,
-			PendingContactFactory pendingContactFactory) {
+			PendingContactFactory pendingContactFactory,
+			EventBus eventBus) {
 		this.db = db;
 		this.keyManager = keyManager;
 		this.identityManager = identityManager;
 		this.pendingContactFactory = pendingContactFactory;
-		hooks = new CopyOnWriteArrayList<>();
+		this.eventBus = eventBus;
 	}
 
 	@Override
@@ -139,6 +158,7 @@ class ContactManagerImpl implements ContactManager {
 		} finally {
 			db.endTransaction(txn);
 		}
+		setState(p.getId(), WAITING_FOR_CONNECTION);
 		return p;
 	}
 
@@ -156,7 +176,12 @@ class ContactManagerImpl implements ContactManager {
 		List<Pair<PendingContact, PendingContactState>> pairs =
 				new ArrayList<>(pendingContacts.size());
 		for (PendingContact p : pendingContacts) {
-			pairs.add(new Pair<>(p, WAITING_FOR_CONNECTION)); // TODO
+			PendingContactState state;
+			synchronized (statesLock) {
+				state = states.get(p.getId());
+			}
+			if (state == null) state = WAITING_FOR_CONNECTION;
+			pairs.add(new Pair<>(p, state));
 		}
 		return pairs;
 	}
@@ -164,6 +189,9 @@ class ContactManagerImpl implements ContactManager {
 	@Override
 	public void removePendingContact(PendingContactId p) throws DbException {
 		db.transaction(false, txn -> db.removePendingContact(txn, p));
+		synchronized (statesLock) {
+			states.remove(p);
+		}
 	}
 
 	@Override
@@ -263,4 +291,34 @@ class ContactManagerImpl implements ContactManager {
 		else return new AuthorInfo(UNVERIFIED, c.getAlias());
 	}
 
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof RendezvousConnectionOpenedEvent) {
+			RendezvousConnectionOpenedEvent r =
+					(RendezvousConnectionOpenedEvent) e;
+			setState(r.getPendingContactId(), ADDING_CONTACT);
+		} else if (e instanceof RendezvousConnectionClosedEvent) {
+			RendezvousConnectionClosedEvent r =
+					(RendezvousConnectionClosedEvent) e;
+			// We're only interested in failures - if the rendezvous succeeds
+			// the pending contact will be removed
+			if (!r.isSuccess())
+				setState(r.getPendingContactId(), WAITING_FOR_CONNECTION);
+		} else if (e instanceof RendezvousFailedEvent) {
+			RendezvousFailedEvent r = (RendezvousFailedEvent) e;
+			setState(r.getPendingContactId(), FAILED);
+		}
+	}
+
+	/*
+	 * Sets the state of the given pending contact and broadcasts an event,
+	 * unless the current state is FAILED.
+	 */
+	private void setState(PendingContactId p, PendingContactState state) {
+		synchronized (statesLock) {
+			if (states.get(p) == FAILED) return;
+			states.put(p, state);
+			eventBus.broadcast(new PendingContactStateChangedEvent(p, state));
+		}
+	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
index f4d6df9f3d0caec5cf3ddfc69b4ace9635de452f..f0b5b6259f072fd2d03fd7f750b2ab44656ab655 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
@@ -3,6 +3,7 @@ package org.briarproject.bramble.contact;
 import org.briarproject.bramble.api.contact.ContactExchangeManager;
 import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.contact.HandshakeManager;
+import org.briarproject.bramble.api.event.EventBus;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -20,7 +21,9 @@ public class ContactModule {
 
 	@Provides
 	@Singleton
-	ContactManager provideContactManager(ContactManagerImpl contactManager) {
+	ContactManager provideContactManager(EventBus eventBus,
+			ContactManagerImpl contactManager) {
+		eventBus.addListener(contactManager);
 		return contactManager;
 	}
 
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
index 1eab88e887710bd2b5a0fa0c6818880e6ebe937c..a96f730fc6fb62376e1844be28deb19f3b199f78 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
@@ -11,6 +11,7 @@ import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.identity.AuthorInfo;
@@ -20,6 +21,7 @@ import org.briarproject.bramble.api.transport.KeyManager;
 import org.briarproject.bramble.test.BrambleMockTestCase;
 import org.briarproject.bramble.test.DbExpectations;
 import org.jmock.Expectations;
+import org.junit.Before;
 import org.junit.Test;
 
 import java.util.Collection;
@@ -54,7 +56,8 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 			context.mock(IdentityManager.class);
 	private final PendingContactFactory pendingContactFactory =
 			context.mock(PendingContactFactory.class);
-	private final ContactManager contactManager;
+	private final EventBus eventBus = context.mock(EventBus.class);
+
 	private final Author remote = getAuthor();
 	private final LocalAuthor localAuthor = getLocalAuthor();
 	private final AuthorId local = localAuthor.getId();
@@ -62,9 +65,12 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	private final Contact contact = getContact(remote, local, verified);
 	private final ContactId contactId = contact.getId();
 
-	public ContactManagerImplTest() {
+	private ContactManager contactManager;
+
+	@Before
+	public void setUp() {
 		contactManager = new ContactManagerImpl(db, keyManager,
-				identityManager, pendingContactFactory);
+				identityManager, pendingContactFactory, eventBus);
 	}
 
 	@Test