diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/nullsafety/NullSafety.java b/bramble-api/src/main/java/org/briarproject/bramble/api/nullsafety/NullSafety.java
index 8e98561ee7f185d0c41b56f4c68b1d25645f5c29..5ff717a96b8a4f42e719aa080fbd121c2b6b9a81 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/nullsafety/NullSafety.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/nullsafety/NullSafety.java
@@ -22,4 +22,11 @@ public class NullSafety {
 			@Nullable Object b) {
 		if ((a == null) == (b == null)) throw new AssertionError();
 	}
+
+	/**
+	 * Checks that the argument is null.
+	 */
+	public static void requireNull(@Nullable Object o) {
+		if (o != null) throw new AssertionError();
+	}
 }
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/plugin/duplex/DuplexPlugin.java b/bramble-api/src/main/java/org/briarproject/bramble/api/plugin/duplex/DuplexPlugin.java
index 4ce802ced90b6fc1f87c15c9ce2098e09c858d7f..ce0e5787e7ee88f313a004b5884ab32ebce622e3 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/plugin/duplex/DuplexPlugin.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/plugin/duplex/DuplexPlugin.java
@@ -3,10 +3,11 @@ package org.briarproject.bramble.api.plugin.duplex;
 import org.briarproject.bramble.api.data.BdfList;
 import org.briarproject.bramble.api.keyagreement.KeyAgreementListener;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.ConnectionHandler;
 import org.briarproject.bramble.api.plugin.Plugin;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
-import org.briarproject.bramble.api.rendezvous.RendezvousHandler;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
 
 import javax.annotation.Nullable;
 
@@ -49,8 +50,12 @@ public interface DuplexPlugin extends Plugin {
 	boolean supportsRendezvous();
 
 	/**
-	 * Creates and returns a handler that uses the given key material to
-	 * rendezvous with a pending contact.
+	 * Creates and returns an endpoint that uses the given key material to
+	 * rendezvous with a pending contact, and the given connection handler to
+	 * handle incoming connections. Returns null if an endpoint cannot be
+	 * created.
 	 */
-	RendezvousHandler createRendezvousHandler(KeyMaterialSource k);
+	@Nullable
+	RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
+			boolean alice, ConnectionHandler incoming);
 }
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousConstants.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousConstants.java
deleted file mode 100644
index 00ac61fe176274b03d8cc3f84fa9875a19d07dbb..0000000000000000000000000000000000000000
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousConstants.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.briarproject.bramble.api.rendezvous;
-
-public interface RendezvousConstants {
-
-	/**
-	 * Label for deriving key material from the master key.
-	 */
-	String KEY_MATERIAL_LABEL =
-			"org.briarproject.bramble.rendezvous/KEY_MATERIAL";
-}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousCrypto.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousCrypto.java
deleted file mode 100644
index e6cacef09bdfd31b2cff08a0171db26f7f6dbbde..0000000000000000000000000000000000000000
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousCrypto.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.briarproject.bramble.api.rendezvous;
-
-import org.briarproject.bramble.api.crypto.SecretKey;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.plugin.TransportId;
-
-@NotNullByDefault
-public interface RendezvousCrypto {
-
-	KeyMaterialSource createKeyMaterialSource(SecretKey masterKey,
-			TransportId t);
-}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousHandler.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousEndpoint.java
similarity index 85%
rename from bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousHandler.java
rename to bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousEndpoint.java
index a48e2d619bd80301d0a4e4d94bbb633ea9a7cc23..07fbb8f53362bae01a02203c4f71ea0c8973dde0 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousHandler.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousEndpoint.java
@@ -2,13 +2,14 @@ package org.briarproject.bramble.api.rendezvous;
 
 import org.briarproject.bramble.api.properties.TransportProperties;
 
+import java.io.Closeable;
 import java.io.IOException;
 
 /**
  * An interface for making and accepting rendezvous connections with a pending
  * contact over a given transport.
  */
-public interface RendezvousHandler {
+public interface RendezvousEndpoint extends Closeable {
 
 	/**
 	 * Returns a set of transport properties for connecting to the pending
@@ -20,5 +21,6 @@ public interface RendezvousHandler {
 	 * Closes the handler and releases any resources held by it, such as
 	 * network sockets.
 	 */
+	@Override
 	void close() throws IOException;
 }
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousFailedEvent.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousFailedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..0530db1e258f97655791adf0e02571358b4bb711
--- /dev/null
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousFailedEvent.java
@@ -0,0 +1,25 @@
+package org.briarproject.bramble.api.rendezvous.event;
+
+import org.briarproject.bramble.api.contact.PendingContactId;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * An event that is broadcast when a rendezvous with a pending contact fails.
+ */
+@Immutable
+@NotNullByDefault
+public class RendezvousFailedEvent extends Event {
+
+	private final PendingContactId pendingContactId;
+
+	public RendezvousFailedEvent(PendingContactId pendingContactId) {
+		this.pendingContactId = pendingContactId;
+	}
+
+	public PendingContactId getPendingContactId() {
+		return pendingContactId;
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java b/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java
index 6ca1c4ec89fecfeea2b874b28e9a92635f8d73ed..d0834d682c8cbd20c8fd4b89edcea2cd3259560e 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java
@@ -7,6 +7,7 @@ import org.briarproject.bramble.identity.IdentityModule;
 import org.briarproject.bramble.lifecycle.LifecycleModule;
 import org.briarproject.bramble.plugin.PluginModule;
 import org.briarproject.bramble.properties.PropertiesModule;
+import org.briarproject.bramble.rendezvous.RendezvousModule;
 import org.briarproject.bramble.sync.validation.ValidationModule;
 import org.briarproject.bramble.system.SystemModule;
 import org.briarproject.bramble.transport.TransportModule;
@@ -28,6 +29,8 @@ public interface BrambleCoreEagerSingletons {
 
 	void inject(PropertiesModule.EagerSingletons init);
 
+	void inject(RendezvousModule.EagerSingletons init);
+
 	void inject(SystemModule.EagerSingletons init);
 
 	void inject(TransportModule.EagerSingletons init);
@@ -42,6 +45,7 @@ public interface BrambleCoreEagerSingletons {
 		inject(new DatabaseExecutorModule.EagerSingletons());
 		inject(new IdentityModule.EagerSingletons());
 		inject(new LifecycleModule.EagerSingletons());
+		inject(new RendezvousModule.EagerSingletons());
 		inject(new PluginModule.EagerSingletons());
 		inject(new PropertiesModule.EagerSingletons());
 		inject(new SystemModule.EagerSingletons());
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
index 04e54027b9fbf04c67cd917cd2ce96586132be66..0dec35bc5dd7709e9a430f297caf1568d97da488 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
@@ -23,7 +23,7 @@ import org.briarproject.bramble.api.plugin.event.DisableBluetoothEvent;
 import org.briarproject.bramble.api.plugin.event.EnableBluetoothEvent;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
-import org.briarproject.bramble.api.rendezvous.RendezvousHandler;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
 import org.briarproject.bramble.api.settings.Settings;
 import org.briarproject.bramble.api.settings.event.SettingsUpdatedEvent;
 
@@ -398,7 +398,8 @@ abstract class BluetoothPlugin<SS> implements DuplexPlugin, EventListener {
 	}
 
 	@Override
-	public RendezvousHandler createRendezvousHandler(KeyMaterialSource k) {
+	public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
+			boolean alice, ConnectionHandler incoming) {
 		throw new UnsupportedOperationException();
 	}
 
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tcp/TcpPlugin.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tcp/TcpPlugin.java
index 5bc57948760c15586faa2bef1b1dabe935b0de89..4ca15d0b986644e216abaf05ed3712d089e58de1 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tcp/TcpPlugin.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tcp/TcpPlugin.java
@@ -13,7 +13,7 @@ import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
 import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
-import org.briarproject.bramble.api.rendezvous.RendezvousHandler;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
 import org.briarproject.bramble.util.IoUtils;
 
 import java.io.IOException;
@@ -309,7 +309,8 @@ abstract class TcpPlugin implements DuplexPlugin {
 	}
 
 	@Override
-	public RendezvousHandler createRendezvousHandler(KeyMaterialSource k) {
+	public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
+			boolean alice, ConnectionHandler incoming) {
 		throw new UnsupportedOperationException();
 	}
 
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java
index 5cfcf5db49ab70e9ab4c7415ca314232a7deb4f0..76d8f6109a99bf7913cbde2809ef8cafad325862 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/tor/TorPlugin.java
@@ -26,7 +26,7 @@ import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
 import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
-import org.briarproject.bramble.api.rendezvous.RendezvousHandler;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
 import org.briarproject.bramble.api.settings.Settings;
 import org.briarproject.bramble.api.settings.event.SettingsUpdatedEvent;
 import org.briarproject.bramble.api.system.Clock;
@@ -613,7 +613,8 @@ abstract class TorPlugin implements DuplexPlugin, EventHandler, EventListener {
 	}
 
 	@Override
-	public RendezvousHandler createRendezvousHandler(KeyMaterialSource k) {
+	public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
+			boolean alice, ConnectionHandler incoming) {
 		throw new UnsupportedOperationException();
 	}
 
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousConstants.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..6a8c7d836d4fdd11c9718c3973b2745b610c102c
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousConstants.java
@@ -0,0 +1,34 @@
+package org.briarproject.bramble.rendezvous;
+
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+interface RendezvousConstants {
+
+	/**
+	 * The current version of the rendezvous protocol.
+	 */
+	byte PROTOCOL_VERSION = 0;
+
+	/**
+	 * How long to try to rendezvous with a pending contact before giving up.
+	 */
+	long RENDEZVOUS_TIMEOUT_MS = DAYS.toMillis(2);
+
+	/**
+	 * How often to try to rendezvous with pending contacts.
+	 */
+	long POLLING_INTERVAL_MS = MINUTES.toMillis(1);
+
+	/**
+	 * Label for deriving the rendezvous key from the static master key.
+	 */
+	String RENDEZVOUS_KEY_LABEL =
+			"org.briarproject.bramble.rendezvous/RENDEZVOUS_KEY";
+
+	/**
+	 * Label for deriving key material from the rendezvous key.
+	 */
+	String KEY_MATERIAL_LABEL =
+			"org.briarproject.bramble.rendezvous/KEY_MATERIAL";
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCrypto.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCrypto.java
new file mode 100644
index 0000000000000000000000000000000000000000..0fcd84417ac0e84205085b1e55c1597bbf97f72e
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCrypto.java
@@ -0,0 +1,15 @@
+package org.briarproject.bramble.rendezvous;
+
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
+
+@NotNullByDefault
+interface RendezvousCrypto {
+
+	SecretKey deriveRendezvousKey(SecretKey staticMasterKey);
+
+	KeyMaterialSource createKeyMaterialSource(SecretKey rendezvousKey,
+			TransportId t);
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCryptoImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCryptoImpl.java
index 01877ae4885d2d046e4f67d33f3815a3258e2fb9..c0cf89f944e8847291920445ce03fa1a91698055 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCryptoImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousCryptoImpl.java
@@ -5,12 +5,13 @@ import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.plugin.TransportId;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
-import org.briarproject.bramble.api.rendezvous.RendezvousCrypto;
 
 import javax.annotation.concurrent.Immutable;
 import javax.inject.Inject;
 
-import static org.briarproject.bramble.api.rendezvous.RendezvousConstants.KEY_MATERIAL_LABEL;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.KEY_MATERIAL_LABEL;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.PROTOCOL_VERSION;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.RENDEZVOUS_KEY_LABEL;
 import static org.briarproject.bramble.util.StringUtils.toUtf8;
 
 @Immutable
@@ -25,10 +26,16 @@ class RendezvousCryptoImpl implements RendezvousCrypto {
 	}
 
 	@Override
-	public KeyMaterialSource createKeyMaterialSource(SecretKey masterKey,
+	public SecretKey deriveRendezvousKey(SecretKey staticMasterKey) {
+		return crypto.deriveKey(RENDEZVOUS_KEY_LABEL, staticMasterKey,
+				new byte[] {PROTOCOL_VERSION});
+	}
+
+	@Override
+	public KeyMaterialSource createKeyMaterialSource(SecretKey rendezvousKey,
 			TransportId t) {
-		SecretKey sourceKey = crypto.deriveKey(KEY_MATERIAL_LABEL, masterKey,
-				toUtf8(t.getString()));
+		SecretKey sourceKey = crypto.deriveKey(KEY_MATERIAL_LABEL,
+				rendezvousKey, toUtf8(t.getString()));
 		return new KeyMaterialSourceImpl(sourceKey);
 	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java
index f166303a5dd9a08034e3db94edb974f22379b845..41bf14f66a09b74cb40eb47591e2a0b03ab57b8a 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java
@@ -1,6 +1,10 @@
 package org.briarproject.bramble.rendezvous;
 
-import org.briarproject.bramble.api.rendezvous.RendezvousCrypto;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.lifecycle.LifecycleManager;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
 
 import dagger.Module;
 import dagger.Provides;
@@ -8,9 +12,23 @@ import dagger.Provides;
 @Module
 public class RendezvousModule {
 
+	public static class EagerSingletons {
+		@Inject
+		RendezvousPoller rendezvousPoller;
+	}
+
 	@Provides
 	RendezvousCrypto provideRendezvousCrypto(
 			RendezvousCryptoImpl rendezvousCrypto) {
 		return rendezvousCrypto;
 	}
+
+	@Provides
+	@Singleton
+	RendezvousPoller provideRendezvousPoller(LifecycleManager lifecycleManager,
+			EventBus eventBus, RendezvousPollerImpl rendezvousPoller) {
+		lifecycleManager.registerService(rendezvousPoller);
+		eventBus.addListener(rendezvousPoller);
+		return rendezvousPoller;
+	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPoller.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPoller.java
new file mode 100644
index 0000000000000000000000000000000000000000..4ecfc32a6aaa7eb863ee97b029348c9266c1eb7f
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPoller.java
@@ -0,0 +1,7 @@
+package org.briarproject.bramble.rendezvous;
+
+/**
+ * Empty interface for injecting the rendezvous poller.
+ */
+interface RendezvousPoller {
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..b7d355548a545fdeaa77d1b41f93969586b18330
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java
@@ -0,0 +1,365 @@
+package org.briarproject.bramble.rendezvous;
+
+import org.briarproject.bramble.PoliteExecutor;
+import org.briarproject.bramble.api.Pair;
+import org.briarproject.bramble.api.contact.PendingContact;
+import org.briarproject.bramble.api.contact.PendingContactId;
+import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent;
+import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent;
+import org.briarproject.bramble.api.crypto.KeyPair;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.crypto.TransportCrypto;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.event.EventExecutor;
+import org.briarproject.bramble.api.event.EventListener;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.lifecycle.IoExecutor;
+import org.briarproject.bramble.api.lifecycle.Service;
+import org.briarproject.bramble.api.lifecycle.ServiceException;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.ConnectionHandler;
+import org.briarproject.bramble.api.plugin.ConnectionManager;
+import org.briarproject.bramble.api.plugin.Plugin;
+import org.briarproject.bramble.api.plugin.PluginManager;
+import org.briarproject.bramble.api.plugin.TransportConnectionReader;
+import org.briarproject.bramble.api.plugin.TransportConnectionWriter;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
+import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
+import org.briarproject.bramble.api.plugin.event.TransportDisabledEvent;
+import org.briarproject.bramble.api.plugin.event.TransportEnabledEvent;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.api.system.Scheduler;
+
+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.Map.Entry;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
+import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNull;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.POLLING_INTERVAL_MS;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.RENDEZVOUS_TIMEOUT_MS;
+import static org.briarproject.bramble.util.IoUtils.tryToClose;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+@NotNullByDefault
+class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
+
+	private static final Logger LOG =
+			getLogger(RendezvousPollerImpl.class.getName());
+
+	private final ScheduledExecutorService scheduler;
+	private final DatabaseComponent db;
+	private final IdentityManager identityManager;
+	private final TransportCrypto transportCrypto;
+	private final RendezvousCrypto rendezvousCrypto;
+	private final PluginManager pluginManager;
+	private final ConnectionManager connectionManager;
+	private final EventBus eventBus;
+	private final Clock clock;
+
+	// Executor that runs one task at a time
+	private final Executor worker;
+	// The following fields are only accessed on the worker
+	private final Map<TransportId, PluginState> pluginStates = new HashMap<>();
+	private final Map<PendingContactId, CryptoState> cryptoStates =
+			new HashMap<>();
+	@Nullable
+	private KeyPair handshakeKeyPair = null;
+
+	@Inject
+	RendezvousPollerImpl(@IoExecutor Executor ioExecutor,
+			@Scheduler ScheduledExecutorService scheduler,
+			DatabaseComponent db,
+			IdentityManager identityManager,
+			TransportCrypto transportCrypto,
+			RendezvousCrypto rendezvousCrypto,
+			PluginManager pluginManager,
+			ConnectionManager connectionManager,
+			EventBus eventBus,
+			Clock clock) {
+		this.scheduler = scheduler;
+		this.db = db;
+		this.identityManager = identityManager;
+		this.transportCrypto = transportCrypto;
+		this.rendezvousCrypto = rendezvousCrypto;
+		this.pluginManager = pluginManager;
+		this.connectionManager = connectionManager;
+		this.eventBus = eventBus;
+		this.clock = clock;
+		worker = new PoliteExecutor("RendezvousPoller", ioExecutor, 1);
+	}
+
+	@Override
+	public void startService() throws ServiceException {
+		try {
+			db.transaction(true, txn -> {
+				Collection<PendingContact> pending = db.getPendingContacts(txn);
+				// Use a commit action to prevent races with add/remove events
+				txn.attach(() -> addPendingContactsAsync(pending));
+			});
+		} catch (DbException e) {
+			throw new ServiceException(e);
+		}
+		scheduler.scheduleAtFixedRate(this::poll, POLLING_INTERVAL_MS,
+				POLLING_INTERVAL_MS, MILLISECONDS);
+	}
+
+	@EventExecutor
+	private void addPendingContactsAsync(Collection<PendingContact> pending) {
+		worker.execute(() -> {
+			for (PendingContact p : pending) addPendingContact(p);
+		});
+	}
+
+	// Worker
+	private void addPendingContact(PendingContact p) {
+		long now = clock.currentTimeMillis();
+		long expiry = p.getTimestamp() + RENDEZVOUS_TIMEOUT_MS;
+		if (expiry > now) {
+			scheduler.schedule(() -> expirePendingContactAsync(p.getId()),
+					expiry - now, MILLISECONDS);
+		} else {
+			eventBus.broadcast(new RendezvousFailedEvent(p.getId()));
+			return;
+		}
+		try {
+			if (handshakeKeyPair == null) {
+				handshakeKeyPair = db.transactionWithResult(true,
+						identityManager::getHandshakeKeys);
+			}
+			SecretKey staticMasterKey = transportCrypto
+					.deriveStaticMasterKey(p.getPublicKey(), handshakeKeyPair);
+			SecretKey rendezvousKey = rendezvousCrypto
+					.deriveRendezvousKey(staticMasterKey);
+			boolean alice = transportCrypto
+					.isAlice(p.getPublicKey(), handshakeKeyPair);
+			CryptoState cs = new CryptoState(rendezvousKey, alice);
+			requireNull(cryptoStates.put(p.getId(), cs));
+			for (PluginState ps : pluginStates.values()) {
+				RendezvousEndpoint endpoint =
+						createEndpoint(ps.plugin, p.getId(), cs);
+				if (endpoint != null)
+					requireNull(ps.endpoints.put(p.getId(), endpoint));
+			}
+		} catch (DbException | GeneralSecurityException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	@Scheduler
+	private void expirePendingContactAsync(PendingContactId p) {
+		worker.execute(() -> expirePendingContact(p));
+	}
+
+	// Worker
+	private void expirePendingContact(PendingContactId p) {
+		if (removePendingContact(p))
+			eventBus.broadcast(new RendezvousFailedEvent(p));
+	}
+
+	// Worker
+	private boolean removePendingContact(PendingContactId p) {
+		// We can come here twice if a pending contact fails and is then removed
+		if (cryptoStates.remove(p) == null) return false;
+		for (PluginState ps : pluginStates.values()) {
+			RendezvousEndpoint endpoint = ps.endpoints.remove(p);
+			if (endpoint != null) tryToClose(endpoint, LOG, INFO);
+		}
+		return true;
+	}
+
+	@Nullable
+	private RendezvousEndpoint createEndpoint(DuplexPlugin plugin,
+			PendingContactId p, CryptoState cs) {
+		TransportId t = plugin.getId();
+		KeyMaterialSource k =
+				rendezvousCrypto.createKeyMaterialSource(cs.rendezvousKey, t);
+		Handler h = new Handler(p, t, true);
+		return plugin.createRendezvousEndpoint(k, cs.alice, h);
+	}
+
+	@Scheduler
+	private void poll() {
+		worker.execute(() -> {
+			for (PluginState ps : pluginStates.values()) poll(ps);
+		});
+	}
+
+	// Worker
+	private void poll(PluginState ps) {
+		List<Pair<TransportProperties, ConnectionHandler>> properties =
+				new ArrayList<>();
+		for (Entry<PendingContactId, RendezvousEndpoint> e :
+				ps.endpoints.entrySet()) {
+			TransportProperties props =
+					e.getValue().getRemoteTransportProperties();
+			Handler h = new Handler(e.getKey(), ps.plugin.getId(), false);
+			properties.add(new Pair<>(props, h));
+		}
+		ps.plugin.poll(properties);
+	}
+
+	@Override
+	public void stopService() {
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof PendingContactAddedEvent) {
+			PendingContactAddedEvent p = (PendingContactAddedEvent) e;
+			addPendingContactAsync(p.getPendingContact());
+		} else if (e instanceof PendingContactRemovedEvent) {
+			PendingContactRemovedEvent p = (PendingContactRemovedEvent) e;
+			removePendingContactAsync(p.getId());
+		} else if (e instanceof TransportEnabledEvent) {
+			TransportEnabledEvent t = (TransportEnabledEvent) e;
+			addTransportAsync(t.getTransportId());
+		} else if (e instanceof TransportDisabledEvent) {
+			TransportDisabledEvent t = (TransportDisabledEvent) e;
+			removeTransportAsync(t.getTransportId());
+		}
+	}
+
+	@EventExecutor
+	private void addPendingContactAsync(PendingContact p) {
+		worker.execute(() -> {
+			addPendingContact(p);
+			poll(p.getId());
+		});
+	}
+
+	// Worker
+	private void poll(PendingContactId p) {
+		for (PluginState ps : pluginStates.values()) {
+			RendezvousEndpoint endpoint = ps.endpoints.get(p);
+			if (endpoint != null) {
+				TransportProperties props =
+						endpoint.getRemoteTransportProperties();
+				Handler h = new Handler(p, ps.plugin.getId(), false);
+				ps.plugin.poll(singletonList(new Pair<>(props, h)));
+			}
+		}
+	}
+
+	@EventExecutor
+	private void removePendingContactAsync(PendingContactId p) {
+		worker.execute(() -> removePendingContact(p));
+	}
+
+	@EventExecutor
+	private void addTransportAsync(TransportId t) {
+		Plugin p = pluginManager.getPlugin(t);
+		if (p instanceof DuplexPlugin) {
+			DuplexPlugin d = (DuplexPlugin) p;
+			if (d.supportsRendezvous())
+				worker.execute(() -> addTransport(d));
+		}
+	}
+
+	// Worker
+	private void addTransport(DuplexPlugin plugin) {
+		TransportId t = plugin.getId();
+		Map<PendingContactId, RendezvousEndpoint> endpoints = new HashMap<>();
+		for (Entry<PendingContactId, CryptoState> e : cryptoStates.entrySet()) {
+			RendezvousEndpoint endpoint =
+					createEndpoint(plugin, e.getKey(), e.getValue());
+			if (endpoint != null) endpoints.put(e.getKey(), endpoint);
+		}
+		requireNull(pluginStates.put(t, new PluginState(plugin, endpoints)));
+	}
+
+	@EventExecutor
+	private void removeTransportAsync(TransportId t) {
+		worker.execute(() -> removeTransport(t));
+	}
+
+	// Worker
+	private void removeTransport(TransportId t) {
+		PluginState ps = pluginStates.remove(t);
+		if (ps != null) {
+			for (RendezvousEndpoint endpoint : ps.endpoints.values()) {
+				tryToClose(endpoint, LOG, INFO);
+			}
+		}
+	}
+
+	private static class PluginState {
+
+		private final DuplexPlugin plugin;
+		private final Map<PendingContactId, RendezvousEndpoint> endpoints;
+
+		private PluginState(DuplexPlugin plugin,
+				Map<PendingContactId, RendezvousEndpoint> endpoints) {
+			this.plugin = plugin;
+			this.endpoints = endpoints;
+		}
+	}
+
+	private static class CryptoState {
+
+		private final SecretKey rendezvousKey;
+		private final boolean alice;
+
+		private CryptoState(SecretKey rendezvousKey, boolean alice) {
+			this.rendezvousKey = rendezvousKey;
+			this.alice = alice;
+		}
+	}
+
+	private class Handler implements ConnectionHandler {
+
+		private final PendingContactId pendingContactId;
+		private final TransportId transportId;
+		private final boolean incoming;
+
+		private Handler(PendingContactId pendingContactId,
+				TransportId transportId, boolean incoming) {
+			this.pendingContactId = pendingContactId;
+			this.transportId = transportId;
+			this.incoming = incoming;
+		}
+
+		@Override
+		public void handleConnection(DuplexTransportConnection c) {
+			if (incoming) {
+				connectionManager.manageIncomingConnection(pendingContactId,
+						transportId, c);
+			} else {
+				connectionManager.manageOutgoingConnection(pendingContactId,
+						transportId, c);
+			}
+		}
+
+		@Override
+		public void handleReader(TransportConnectionReader r) {
+			throw new UnsupportedOperationException();
+		}
+
+		@Override
+		public void handleWriter(TransportConnectionWriter w) {
+			throw new UnsupportedOperationException();
+		}
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..92af0f780eda0db9d74b35890f61d2fda9d0f488
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java
@@ -0,0 +1,359 @@
+package org.briarproject.bramble.rendezvous;
+
+import org.briarproject.bramble.api.contact.PendingContact;
+import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent;
+import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent;
+import org.briarproject.bramble.api.crypto.KeyPair;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.crypto.TransportCrypto;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.plugin.ConnectionHandler;
+import org.briarproject.bramble.api.plugin.ConnectionManager;
+import org.briarproject.bramble.api.plugin.PluginManager;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
+import org.briarproject.bramble.api.plugin.event.TransportDisabledEvent;
+import org.briarproject.bramble.api.plugin.event.TransportEnabledEvent;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.test.BrambleMockTestCase;
+import org.briarproject.bramble.test.CaptureArgumentAction;
+import org.briarproject.bramble.test.DbExpectations;
+import org.briarproject.bramble.test.ImmediateExecutor;
+import org.jmock.Expectations;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Random;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.POLLING_INTERVAL_MS;
+import static org.briarproject.bramble.rendezvous.RendezvousConstants.RENDEZVOUS_TIMEOUT_MS;
+import static org.briarproject.bramble.test.CollectionMatcher.collectionOf;
+import static org.briarproject.bramble.test.PairMatcher.pairOf;
+import static org.briarproject.bramble.test.TestUtils.getAgreementPrivateKey;
+import static org.briarproject.bramble.test.TestUtils.getAgreementPublicKey;
+import static org.briarproject.bramble.test.TestUtils.getPendingContact;
+import static org.briarproject.bramble.test.TestUtils.getSecretKey;
+import static org.briarproject.bramble.test.TestUtils.getTransportId;
+import static org.briarproject.bramble.test.TestUtils.getTransportProperties;
+
+public class RendezvousPollerImplTest extends BrambleMockTestCase {
+
+	private final ScheduledExecutorService scheduler =
+			context.mock(ScheduledExecutorService.class);
+	private final DatabaseComponent db = context.mock(DatabaseComponent.class);
+	private final IdentityManager identityManager =
+			context.mock(IdentityManager.class);
+	private final TransportCrypto transportCrypto =
+			context.mock(TransportCrypto.class);
+	private final RendezvousCrypto rendezvousCrypto =
+			context.mock(RendezvousCrypto.class);
+	private final PluginManager pluginManager =
+			context.mock(PluginManager.class);
+	private final ConnectionManager connectionManager =
+			context.mock(ConnectionManager.class);
+	private final EventBus eventBus = context.mock(EventBus.class);
+	private final Clock clock = context.mock(Clock.class);
+	private final DuplexPlugin plugin = context.mock(DuplexPlugin.class);
+	private final KeyMaterialSource keyMaterialSource =
+			context.mock(KeyMaterialSource.class);
+	private final RendezvousEndpoint rendezvousEndpoint =
+			context.mock(RendezvousEndpoint.class);
+
+	private final Executor ioExecutor = new ImmediateExecutor();
+	private final PendingContact pendingContact = getPendingContact();
+	private final KeyPair handshakeKeyPair =
+			new KeyPair(getAgreementPublicKey(), getAgreementPrivateKey());
+	private final SecretKey staticMasterKey = getSecretKey();
+	private final SecretKey rendezvousKey = getSecretKey();
+	private final TransportId transportId = getTransportId();
+	private final TransportProperties transportProperties =
+			getTransportProperties(3);
+	private final boolean alice = new Random().nextBoolean();
+
+	private RendezvousPollerImpl rendezvousPoller;
+
+	@Before
+	public void setUp() {
+		rendezvousPoller = new RendezvousPollerImpl(ioExecutor, scheduler, db,
+				identityManager, transportCrypto, rendezvousCrypto,
+				pluginManager, connectionManager, eventBus, clock);
+	}
+
+	@Test
+	public void testAddsPendingContactsAndSchedulesExpiryAtStartup()
+			throws Exception {
+		Transaction txn = new Transaction(null, true);
+		long now = pendingContact.getTimestamp() + RENDEZVOUS_TIMEOUT_MS - 1000;
+		AtomicReference<Runnable> captureExpiryTask = new AtomicReference<>();
+
+		context.checking(new DbExpectations() {{
+			// Load the pending contacts
+			oneOf(db).transaction(with(true), withDbRunnable(txn));
+			oneOf(db).getPendingContacts(txn);
+			will(returnValue(singletonList(pendingContact)));
+			// Schedule the first poll
+			oneOf(scheduler).scheduleAtFixedRate(with(any(Runnable.class)),
+					with(POLLING_INTERVAL_MS), with(POLLING_INTERVAL_MS),
+					with(MILLISECONDS));
+			// Calculate the pending contact's expiry time, 1 second from now
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			// Capture the expiry task, we'll run it later
+			oneOf(scheduler).schedule(with(any(Runnable.class)), with(1000L),
+					with(MILLISECONDS));
+			will(new CaptureArgumentAction<>(captureExpiryTask, Runnable.class,
+					0));
+			// Load our handshake key pair
+			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
+			will(returnValue(handshakeKeyPair));
+			// Derive the rendezvous key
+			oneOf(transportCrypto).deriveStaticMasterKey(
+					pendingContact.getPublicKey(), handshakeKeyPair);
+			will(returnValue(staticMasterKey));
+			oneOf(rendezvousCrypto).deriveRendezvousKey(staticMasterKey);
+			will(returnValue(rendezvousKey));
+			oneOf(transportCrypto).isAlice(pendingContact.getPublicKey(),
+					handshakeKeyPair);
+			will(returnValue(alice));
+		}});
+
+		rendezvousPoller.startService();
+		context.assertIsSatisfied();
+
+		context.checking(new Expectations() {{
+			// Run the expiry task
+			oneOf(eventBus).broadcast(with(any(RendezvousFailedEvent.class)));
+		}});
+
+		captureExpiryTask.get().run();
+	}
+
+	@Test
+	public void testBroadcastsEventWhenExpiredPendingContactIsAdded() {
+		long now = pendingContact.getTimestamp() + RENDEZVOUS_TIMEOUT_MS;
+
+		context.checking(new Expectations() {{
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			oneOf(eventBus).broadcast(with(any(RendezvousFailedEvent.class)));
+		}});
+
+		rendezvousPoller.eventOccurred(
+				new PendingContactAddedEvent(pendingContact));
+	}
+
+	@Test
+	public void testCreatesAndClosesEndpointsWhenPendingContactIsAddedAndRemoved()
+			throws Exception {
+		Transaction txn = new Transaction(null, true);
+		long now = pendingContact.getTimestamp();
+
+		// Enable the transport - no endpoints should be created yet
+		context.checking(new Expectations() {{
+			oneOf(pluginManager).getPlugin(transportId);
+			will(returnValue(plugin));
+			oneOf(plugin).supportsRendezvous();
+			will(returnValue(true));
+			allowing(plugin).getId();
+			will(returnValue(transportId));
+		}});
+
+		rendezvousPoller.eventOccurred(new TransportEnabledEvent(transportId));
+		context.assertIsSatisfied();
+
+		// Add the pending contact - endpoint should be created and polled
+		context.checking(new DbExpectations() {{
+			// Add pending contact
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			oneOf(scheduler).schedule(with(any(Runnable.class)),
+					with(RENDEZVOUS_TIMEOUT_MS), with(MILLISECONDS));
+			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
+			will(returnValue(handshakeKeyPair));
+			oneOf(transportCrypto).deriveStaticMasterKey(
+					pendingContact.getPublicKey(), handshakeKeyPair);
+			will(returnValue(staticMasterKey));
+			oneOf(rendezvousCrypto).deriveRendezvousKey(staticMasterKey);
+			will(returnValue(rendezvousKey));
+			oneOf(transportCrypto).isAlice(pendingContact.getPublicKey(),
+					handshakeKeyPair);
+			will(returnValue(alice));
+			oneOf(rendezvousCrypto).createKeyMaterialSource(rendezvousKey,
+					transportId);
+			will(returnValue(keyMaterialSource));
+			oneOf(plugin).createRendezvousEndpoint(with(keyMaterialSource),
+					with(alice), with(any(ConnectionHandler.class)));
+			will(returnValue(rendezvousEndpoint));
+			// Poll newly added pending contact
+			oneOf(rendezvousEndpoint).getRemoteTransportProperties();
+			will(returnValue(transportProperties));
+			oneOf(plugin).poll(with(collectionOf(pairOf(
+					equal(transportProperties),
+					any(ConnectionHandler.class)))));
+		}});
+
+		rendezvousPoller.eventOccurred(
+				new PendingContactAddedEvent(pendingContact));
+		context.assertIsSatisfied();
+
+		// Remove the pending contact - endpoint should be closed
+		context.checking(new Expectations() {{
+			oneOf(rendezvousEndpoint).close();
+		}});
+
+		rendezvousPoller.eventOccurred(
+				new PendingContactRemovedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Disable the transport - endpoint is already closed
+		rendezvousPoller.eventOccurred(new TransportDisabledEvent(transportId));
+	}
+
+	@Test
+	public void testCreatesAndClosesEndpointsWhenPendingContactIsAddedAndExpired()
+			throws Exception {
+		Transaction txn = new Transaction(null, true);
+		long now = pendingContact.getTimestamp();
+		AtomicReference<Runnable> captureExpiryTask = new AtomicReference<>();
+
+		// Enable the transport - no endpoints should be created yet
+		context.checking(new Expectations() {{
+			oneOf(pluginManager).getPlugin(transportId);
+			will(returnValue(plugin));
+			oneOf(plugin).supportsRendezvous();
+			will(returnValue(true));
+			allowing(plugin).getId();
+			will(returnValue(transportId));
+		}});
+
+		rendezvousPoller.eventOccurred(new TransportEnabledEvent(transportId));
+		context.assertIsSatisfied();
+
+		// Add the pending contact - endpoint should be created and polled
+		context.checking(new DbExpectations() {{
+			// Add pending contact
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			// Capture the expiry task, we'll run it later
+			oneOf(scheduler).schedule(with(any(Runnable.class)),
+					with(RENDEZVOUS_TIMEOUT_MS), with(MILLISECONDS));
+			will(new CaptureArgumentAction<>(captureExpiryTask, Runnable.class,
+					0));
+			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
+			will(returnValue(handshakeKeyPair));
+			oneOf(transportCrypto).deriveStaticMasterKey(
+					pendingContact.getPublicKey(), handshakeKeyPair);
+			will(returnValue(staticMasterKey));
+			oneOf(rendezvousCrypto).deriveRendezvousKey(staticMasterKey);
+			will(returnValue(rendezvousKey));
+			oneOf(transportCrypto).isAlice(pendingContact.getPublicKey(),
+					handshakeKeyPair);
+			will(returnValue(alice));
+			oneOf(rendezvousCrypto).createKeyMaterialSource(rendezvousKey,
+					transportId);
+			will(returnValue(keyMaterialSource));
+			oneOf(plugin).createRendezvousEndpoint(with(keyMaterialSource),
+					with(alice), with(any(ConnectionHandler.class)));
+			will(returnValue(rendezvousEndpoint));
+			// Poll newly added pending contact
+			oneOf(rendezvousEndpoint).getRemoteTransportProperties();
+			will(returnValue(transportProperties));
+			oneOf(plugin).poll(with(collectionOf(pairOf(
+					equal(transportProperties),
+					any(ConnectionHandler.class)))));
+		}});
+
+		rendezvousPoller.eventOccurred(
+				new PendingContactAddedEvent(pendingContact));
+		context.assertIsSatisfied();
+
+		// The pending contact expires - endpoint should be closed
+		context.checking(new Expectations() {{
+			oneOf(rendezvousEndpoint).close();
+			oneOf(eventBus).broadcast(with(any(RendezvousFailedEvent.class)));
+		}});
+
+		captureExpiryTask.get().run();
+		context.assertIsSatisfied();
+
+		// Remove the pending contact - endpoint is already closed
+		rendezvousPoller.eventOccurred(
+				new PendingContactRemovedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Disable the transport - endpoint is already closed
+		rendezvousPoller.eventOccurred(new TransportDisabledEvent(transportId));
+	}
+
+	@Test
+	public void testCreatesAndClosesEndpointsWhenTransportIsEnabledAndDisabled()
+			throws Exception {
+		Transaction txn = new Transaction(null, true);
+		long now = pendingContact.getTimestamp();
+
+		// Add the pending contact - no endpoints should be created yet
+		context.checking(new DbExpectations() {{
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			oneOf(scheduler).schedule(with(any(Runnable.class)),
+					with(RENDEZVOUS_TIMEOUT_MS), with(MILLISECONDS));
+			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
+			will(returnValue(handshakeKeyPair));
+			oneOf(transportCrypto).deriveStaticMasterKey(
+					pendingContact.getPublicKey(), handshakeKeyPair);
+			will(returnValue(staticMasterKey));
+			oneOf(rendezvousCrypto).deriveRendezvousKey(staticMasterKey);
+			will(returnValue(rendezvousKey));
+			oneOf(transportCrypto).isAlice(pendingContact.getPublicKey(),
+					handshakeKeyPair);
+			will(returnValue(alice));
+		}});
+
+		rendezvousPoller.eventOccurred(
+				new PendingContactAddedEvent(pendingContact));
+		context.assertIsSatisfied();
+
+		// Enable the transport - endpoint should be created
+		context.checking(new Expectations() {{
+			oneOf(pluginManager).getPlugin(transportId);
+			will(returnValue(plugin));
+			oneOf(plugin).supportsRendezvous();
+			will(returnValue(true));
+			allowing(plugin).getId();
+			will(returnValue(transportId));
+			oneOf(rendezvousCrypto).createKeyMaterialSource(rendezvousKey,
+					transportId);
+			will(returnValue(keyMaterialSource));
+			oneOf(plugin).createRendezvousEndpoint(with(keyMaterialSource),
+					with(alice), with(any(ConnectionHandler.class)));
+			will(returnValue(rendezvousEndpoint));
+		}});
+
+		rendezvousPoller.eventOccurred(new TransportEnabledEvent(transportId));
+		context.assertIsSatisfied();
+
+		// Disable the transport - endpoint should be closed
+		context.checking(new Expectations() {{
+			oneOf(rendezvousEndpoint).close();
+		}});
+
+		rendezvousPoller.eventOccurred(new TransportDisabledEvent(transportId));
+		context.assertIsSatisfied();
+
+		// Remove the pending contact - endpoint is already closed
+		rendezvousPoller.eventOccurred(
+				new PendingContactRemovedEvent(pendingContact.getId()));
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/RunAction.java b/bramble-core/src/test/java/org/briarproject/bramble/test/RunAction.java
index 0ec8d7ac8a61227ff1cb0c0db293f314ac4d144a..79fb712201f24f044067d3d1058c3cc8ed6d851b 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/RunAction.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/RunAction.java
@@ -7,7 +7,7 @@ import org.jmock.api.Invocation;
 public class RunAction implements Action {
 
 	@Override
-	public Object invoke(Invocation invocation) throws Throwable {
+	public Object invoke(Invocation invocation) {
 		Runnable task = (Runnable) invocation.getParameter(0);
 		task.run();
 		return null;
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionAction.java b/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionAction.java
index 7ee016201ec0a835110786d86bd533156a7d48ea..0e55a4be7721410f51abafd72a2709838c7c11a2 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionAction.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionAction.java
@@ -1,17 +1,18 @@
 package org.briarproject.bramble.test;
 
+import org.briarproject.bramble.api.db.CommitAction;
 import org.briarproject.bramble.api.db.DbRunnable;
+import org.briarproject.bramble.api.db.TaskAction;
 import org.briarproject.bramble.api.db.Transaction;
 import org.hamcrest.Description;
 import org.jmock.api.Action;
 import org.jmock.api.Invocation;
 
-public class RunTransactionAction implements Action {
+class RunTransactionAction implements Action {
 
 	private final Transaction txn;
 
-	@SuppressWarnings("WeakerAccess")
-	public RunTransactionAction(Transaction txn) {
+	RunTransactionAction(Transaction txn) {
 		this.txn = txn;
 	}
 
@@ -19,6 +20,10 @@ public class RunTransactionAction implements Action {
 	public Object invoke(Invocation invocation) throws Throwable {
 		DbRunnable task = (DbRunnable) invocation.getParameter(1);
 		task.run(txn);
+		for (CommitAction action : txn.getActions()) {
+			if (action instanceof TaskAction)
+				((TaskAction) action).getTask().run();
+		}
 		return null;
 	}
 
@@ -26,5 +31,4 @@ public class RunTransactionAction implements Action {
 	public void describeTo(Description description) {
 		description.appendText("runs a task inside a database transaction");
 	}
-
 }
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithNullableResultAction.java b/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithNullableResultAction.java
index 9ed991c9266a8737a9293b9c5a07e8b45bfa8dba..1534f32361e7bd074f66ba1164db97d0fb0ad56d 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithNullableResultAction.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithNullableResultAction.java
@@ -1,16 +1,18 @@
 package org.briarproject.bramble.test;
 
+import org.briarproject.bramble.api.db.CommitAction;
 import org.briarproject.bramble.api.db.NullableDbCallable;
+import org.briarproject.bramble.api.db.TaskAction;
 import org.briarproject.bramble.api.db.Transaction;
 import org.hamcrest.Description;
 import org.jmock.api.Action;
 import org.jmock.api.Invocation;
 
-public class RunTransactionWithNullableResultAction implements Action {
+class RunTransactionWithNullableResultAction implements Action {
 
 	private final Transaction txn;
 
-	public RunTransactionWithNullableResultAction(Transaction txn) {
+	RunTransactionWithNullableResultAction(Transaction txn) {
 		this.txn = txn;
 	}
 
@@ -18,7 +20,12 @@ public class RunTransactionWithNullableResultAction implements Action {
 	public Object invoke(Invocation invocation) throws Throwable {
 		NullableDbCallable task =
 				(NullableDbCallable) invocation.getParameter(1);
-		return task.call(txn);
+		Object result = task.call(txn);
+		for (CommitAction action : txn.getActions()) {
+			if (action instanceof TaskAction)
+				((TaskAction) action).getTask().run();
+		}
+		return result;
 	}
 
 	@Override
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithResultAction.java b/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithResultAction.java
index cbd22b1260fc2d020b789c622db4c1ebfb3fa79e..d2089e97d9e404785b78c794cb729e7c1b3912ed 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithResultAction.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/RunTransactionWithResultAction.java
@@ -1,23 +1,30 @@
 package org.briarproject.bramble.test;
 
+import org.briarproject.bramble.api.db.CommitAction;
 import org.briarproject.bramble.api.db.DbCallable;
+import org.briarproject.bramble.api.db.TaskAction;
 import org.briarproject.bramble.api.db.Transaction;
 import org.hamcrest.Description;
 import org.jmock.api.Action;
 import org.jmock.api.Invocation;
 
-public class RunTransactionWithResultAction implements Action {
+class RunTransactionWithResultAction implements Action {
 
 	private final Transaction txn;
 
-	public RunTransactionWithResultAction(Transaction txn) {
+	RunTransactionWithResultAction(Transaction txn) {
 		this.txn = txn;
 	}
 
 	@Override
 	public Object invoke(Invocation invocation) throws Throwable {
 		DbCallable task = (DbCallable) invocation.getParameter(1);
-		return task.call(txn);
+		Object result = task.call(txn);
+		for (CommitAction action : txn.getActions()) {
+			if (action instanceof TaskAction)
+				((TaskAction) action).getTask().run();
+		}
+		return result;
 	}
 
 	@Override
diff --git a/bramble-java/src/main/java/org/briarproject/bramble/plugin/modem/ModemPlugin.java b/bramble-java/src/main/java/org/briarproject/bramble/plugin/modem/ModemPlugin.java
index 980db3af9f59fe112f211f135b9ca09b43a89b79..16a0d0f31f0ab8bb3a251eafc16bb3d1567710b1 100644
--- a/bramble-java/src/main/java/org/briarproject/bramble/plugin/modem/ModemPlugin.java
+++ b/bramble-java/src/main/java/org/briarproject/bramble/plugin/modem/ModemPlugin.java
@@ -14,7 +14,7 @@ import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
 import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
-import org.briarproject.bramble.api.rendezvous.RendezvousHandler;
+import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -192,7 +192,8 @@ class ModemPlugin implements DuplexPlugin, Modem.Callback {
 	}
 
 	@Override
-	public RendezvousHandler createRendezvousHandler(KeyMaterialSource k) {
+	public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
+			boolean alice, ConnectionHandler incoming) {
 		throw new UnsupportedOperationException();
 	}